This commit is contained in:
goat 2023-04-10 19:17:00 +02:00 committed by GitHub
parent 50458444e7
commit 642e8bf6d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1492 additions and 74 deletions

View file

@ -6,6 +6,7 @@ using System.Linq;
using Dalamud.Game.Text;
using Dalamud.Interface.Style;
using Dalamud.Plugin.Internal.Profiles;
using Dalamud.Utility;
using Newtonsoft.Json;
using Serilog;
@ -266,6 +267,21 @@ internal sealed class DalamudConfiguration : IServiceType
/// </summary>
public string ChosenStyle { get; set; } = "Dalamud Standard";
/// <summary>
/// Gets or sets a list of saved plugin profiles.
/// </summary>
public List<ProfileModel>? SavedProfiles { get; set; }
/// <summary>
/// Gets or sets the default plugin profile.
/// </summary>
public ProfileModel? DefaultProfile { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not profiles are enabled.
/// </summary>
public bool ProfilesEnabled { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether or not Dalamud RMT filtering should be disabled.
/// </summary>

View file

@ -28,6 +28,7 @@ internal class PluginCategoryManager
new(11, "special.devIconTester", () => Locs.Category_IconTester),
new(12, "special.dalamud", () => Locs.Category_Dalamud),
new(13, "special.plugins", () => Locs.Category_Plugins),
new(14, "special.profiles", () => Locs.Category_PluginProfiles, CategoryInfo.AppearCondition.ProfilesEnabled),
new(FirstTagBasedCategoryId + 0, "other", () => Locs.Category_Other),
new(FirstTagBasedCategoryId + 1, "jobs", () => Locs.Category_Jobs),
new(FirstTagBasedCategoryId + 2, "ui", () => Locs.Category_UI),
@ -43,7 +44,7 @@ internal class PluginCategoryManager
private GroupInfo[] groupList =
{
new(GroupKind.DevTools, () => Locs.Group_DevTools, 10, 11),
new(GroupKind.Installed, () => Locs.Group_Installed, 0, 1),
new(GroupKind.Installed, () => Locs.Group_Installed, 0, 1, 14),
new(GroupKind.Available, () => Locs.Group_Available, 0),
new(GroupKind.Changelog, () => Locs.Group_Changelog, 0, 12, 13),
@ -352,6 +353,11 @@ internal class PluginCategoryManager
/// Check if plugin testing is enabled.
/// </summary>
DoPluginTest,
/// <summary>
/// Check if plugin profiles are enabled.
/// </summary>
ProfilesEnabled,
}
/// <summary>
@ -429,6 +435,8 @@ internal class PluginCategoryManager
public static string Category_DevInstalled => Loc.Localize("InstallerInstalledDevPlugins", "Installed Dev Plugins");
public static string Category_IconTester => "Image/Icon Tester";
public static string Category_PluginProfiles => Loc.Localize("InstallerCategoryPluginProfiles", "Plugin Profiles");
public static string Category_Other => Loc.Localize("InstallerCategoryOther", "Other");

View file

@ -21,6 +21,7 @@ using Dalamud.Logging.Internal;
using Dalamud.Plugin;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Internal.Exceptions;
using Dalamud.Plugin.Internal.Profiles;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Support;
using Dalamud.Utility;
@ -48,6 +49,8 @@ internal class PluginInstallerWindow : Window, IDisposable
private readonly object listLock = new();
private readonly ProfileManagerWidget profileManagerWidget;
private DalamudChangelogManager? dalamudChangelogManager;
private Task? dalamudChangelogRefreshTask;
private CancellationTokenSource? dalamudChangelogRefreshTaskCts;
@ -149,6 +152,8 @@ internal class PluginInstallerWindow : Window, IDisposable
});
this.timeLoaded = DateTime.Now;
this.profileManagerWidget = new(this);
}
private enum OperationStatus
@ -167,6 +172,7 @@ internal class PluginInstallerWindow : Window, IDisposable
UpdatingAll,
Installing,
Manager,
ProfilesLoading,
}
private enum PluginSortKind
@ -213,6 +219,8 @@ internal class PluginInstallerWindow : Window, IDisposable
this.updatePluginCount = 0;
this.updatedPlugins = null;
}
this.profileManagerWidget.Reset();
}
/// <inheritdoc/>
@ -285,17 +293,56 @@ internal class PluginInstallerWindow : Window, IDisposable
this.searchText = text;
}
/// <summary>
/// Start a plugin install and handle errors visually.
/// </summary>
/// <param name="manifest">The manifest to install.</param>
/// <param name="useTesting">Install the testing version.</param>
public void StartInstall(RemotePluginManifest manifest, bool useTesting)
{
var pluginManager = Service<PluginManager>.Get();
var notifications = Service<NotificationManager>.Get();
this.installStatus = OperationStatus.InProgress;
this.loadingIndicatorKind = LoadingIndicatorKind.Installing;
Task.Run(() => pluginManager.InstallPluginAsync(manifest, useTesting || manifest.IsTestingExclusive, PluginLoadReason.Installer))
.ContinueWith(task =>
{
// There is no need to set as Complete for an individual plugin installation
this.installStatus = OperationStatus.Idle;
if (this.DisplayErrorContinuation(task, Locs.ErrorModal_InstallFail(manifest.Name)))
{
// Fine as long as we aren't in an error state
if (task.Result.State is PluginState.Loaded or PluginState.Unloaded)
{
notifications.AddNotification(Locs.Notifications_PluginInstalled(manifest.Name), Locs.Notifications_PluginInstalledTitle, NotificationType.Success);
}
else
{
notifications.AddNotification(Locs.Notifications_PluginNotInstalled(manifest.Name), Locs.Notifications_PluginNotInstalledTitle, NotificationType.Error);
this.ShowErrorModal(Locs.ErrorModal_InstallFail(manifest.Name));
}
}
});
}
private void DrawProgressOverlay()
{
var pluginManager = Service<PluginManager>.Get();
var profileManager = Service<ProfileManager>.Get();
var isWaitingManager = !pluginManager.PluginsReady ||
!pluginManager.ReposReady;
var isWaitingProfiles = profileManager.IsBusy;
var isLoading = this.AnyOperationInProgress ||
isWaitingManager;
isWaitingManager || isWaitingProfiles;
if (isWaitingManager)
this.loadingIndicatorKind = LoadingIndicatorKind.Manager;
else if (isWaitingProfiles)
this.loadingIndicatorKind = LoadingIndicatorKind.ProfilesLoading;
if (!isLoading)
return;
@ -379,6 +426,9 @@ internal class PluginInstallerWindow : Window, IDisposable
}
}
break;
case LoadingIndicatorKind.ProfilesLoading:
ImGuiHelpers.CenteredText("Profiles are being applied...");
break;
default:
throw new ArgumentOutOfRangeException();
@ -387,9 +437,7 @@ internal class PluginInstallerWindow : Window, IDisposable
if (DateTime.Now - this.timeLoaded > TimeSpan.FromSeconds(90) && !pluginManager.PluginsReady)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed);
ImGuiHelpers.CenteredText("This is embarrassing, but...");
ImGuiHelpers.CenteredText("one of your plugins may be blocking the installer.");
ImGuiHelpers.CenteredText("You should tell us about this, please keep this window open.");
ImGuiHelpers.CenteredText("One of your plugins may be blocking the installer.");
ImGui.PopStyleColor();
}
@ -1111,6 +1159,10 @@ internal class PluginInstallerWindow : Window, IDisposable
if (!Service<DalamudConfiguration>.Get().DoPluginTest)
continue;
break;
case PluginCategoryManager.CategoryInfo.AppearCondition.ProfilesEnabled:
if (!Service<DalamudConfiguration>.Get().ProfilesEnabled)
continue;
break;
default:
throw new ArgumentOutOfRangeException();
}
@ -1216,6 +1268,10 @@ internal class PluginInstallerWindow : Window, IDisposable
case 1:
this.DrawInstalledPluginList(true);
break;
case 2:
this.profileManagerWidget.Draw();
break;
}
break;
@ -1555,7 +1611,7 @@ internal class PluginInstallerWindow : Window, IDisposable
ImGui.SetCursorPos(startCursor);
var pluginDisabled = plugin is { IsDisabled: true };
var pluginDisabled = plugin is { IsWantedByAnyProfile: false };
var iconSize = ImGuiHelpers.ScaledVector2(64, 64);
var cursorBeforeImage = ImGui.GetCursorPos();
@ -1853,27 +1909,7 @@ internal class PluginInstallerWindow : Window, IDisposable
var buttonText = Locs.PluginButton_InstallVersion(versionString);
if (ImGui.Button($"{buttonText}##{buttonText}{index}"))
{
this.installStatus = OperationStatus.InProgress;
this.loadingIndicatorKind = LoadingIndicatorKind.Installing;
Task.Run(() => pluginManager.InstallPluginAsync(manifest, useTesting || manifest.IsTestingExclusive, PluginLoadReason.Installer))
.ContinueWith(task =>
{
// There is no need to set as Complete for an individual plugin installation
this.installStatus = OperationStatus.Idle;
if (this.DisplayErrorContinuation(task, Locs.ErrorModal_InstallFail(manifest.Name)))
{
if (task.Result.State == PluginState.Loaded)
{
notifications.AddNotification(Locs.Notifications_PluginInstalled(manifest.Name), Locs.Notifications_PluginInstalledTitle, NotificationType.Success);
}
else
{
notifications.AddNotification(Locs.Notifications_PluginNotInstalled(manifest.Name), Locs.Notifications_PluginNotInstalledTitle, NotificationType.Error);
this.ShowErrorModal(Locs.ErrorModal_InstallFail(manifest.Name));
}
}
});
this.StartInstall(manifest, useTesting);
}
}
@ -1980,7 +2016,7 @@ internal class PluginInstallerWindow : Window, IDisposable
}
// Disabled
if (plugin.IsDisabled || !plugin.CheckPolicy())
if (!plugin.IsWantedByAnyProfile || !plugin.CheckPolicy())
{
label += Locs.PluginTitleMod_Disabled;
trouble = true;
@ -2262,6 +2298,11 @@ internal class PluginInstallerWindow : Window, IDisposable
{
var notifications = Service<NotificationManager>.Get();
var pluginManager = Service<PluginManager>.Get();
var profileManager = Service<ProfileManager>.Get();
var config = Service<DalamudConfiguration>.Get();
var applicableForProfiles = plugin.Manifest.SupportsProfiles;
var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.InternalName);
// Disable everything if the updater is running or another plugin is operating
var disabled = this.updateStatus == OperationStatus.InProgress || this.installStatus == OperationStatus.InProgress;
@ -2276,15 +2317,70 @@ internal class PluginInstallerWindow : Window, IDisposable
// Disable everything if the plugin failed to load
disabled = disabled || plugin.State == PluginState.LoadError || plugin.State == PluginState.DependencyResolutionFailed;
// Disable everything if we're working
// Disable everything if we're loading plugins
disabled = disabled || plugin.State == PluginState.Loading || plugin.State == PluginState.Unloading;
// Disable everything if we're applying profiles
disabled = disabled || profileManager.IsBusy;
var toggleId = plugin.Manifest.InternalName;
var isLoadedAndUnloadable = plugin.State == PluginState.Loaded ||
plugin.State == PluginState.DependencyResolutionFailed;
StyleModelV1.DalamudStandard.Push();
var profileChooserPopupName = $"###pluginProfileChooser{plugin.Manifest.InternalName}";
if (ImGui.BeginPopup(profileChooserPopupName))
{
var didAny = false;
foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile))
{
var inProfile = profile.WantsPlugin(plugin.Manifest.InternalName) != null;
if (ImGui.Checkbox($"###profilePick{profile.Guid}{plugin.Manifest.InternalName}", ref inProfile))
{
if (inProfile)
{
Task.Run(() => profile.AddOrUpdate(plugin.Manifest.InternalName, true))
.ContinueWith(this.DisplayErrorContinuation, "Couldn't add plugin to this profile.");
}
else
{
Task.Run(() => profile.Remove(plugin.Manifest.InternalName))
.ContinueWith(this.DisplayErrorContinuation, "Couldn't remove plugin from this profile.");
}
}
ImGui.SameLine();
ImGui.TextUnformatted(profile.Name);
didAny = true;
}
if (!didAny)
ImGui.TextColored(ImGuiColors.DalamudGrey, "No profiles! Go add some!");
ImGui.Separator();
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
{
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, plugin.IsLoaded, false);
foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile && x.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName)))
{
profile.Remove(plugin.Manifest.InternalName, false);
}
// TODO error handling
Task.Run(() => profileManager.ApplyAllWantStates());
}
ImGui.SameLine();
ImGui.Text("Remove from all profiles");
ImGui.EndPopup();
}
if (plugin.State == PluginState.UnloadError && !plugin.IsDev)
{
ImGuiComponents.DisabledButton(FontAwesomeIcon.Frown);
@ -2292,14 +2388,18 @@ internal class PluginInstallerWindow : Window, IDisposable
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Locs.PluginButtonToolTip_UnloadFailed);
}
else if (disabled)
else if (disabled || !isDefaultPlugin)
{
ImGuiComponents.DisabledToggleButton(toggleId, isLoadedAndUnloadable);
if (!isDefaultPlugin && ImGui.IsItemHovered())
ImGui.SetTooltip(Locs.PluginButtonToolTip_NeedsToBeInDefault);
}
else
{
if (ImGuiComponents.ToggleButton(toggleId, ref isLoadedAndUnloadable))
{
// TODO: We can technically let profile manager take care of unloading/loading the plugin, but we should figure out error handling first.
if (!isLoadedAndUnloadable)
{
this.enableDisableStatus = OperationStatus.InProgress;
@ -2322,15 +2422,9 @@ internal class PluginInstallerWindow : Window, IDisposable
return;
}
var disableTask = Task.Run(() => plugin.Disable())
.ContinueWith(this.DisplayErrorContinuation, Locs.ErrorModal_DisableFail(plugin.Name));
disableTask.Wait();
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, false, false);
this.enableDisableStatus = OperationStatus.Complete;
if (!disableTask.Result)
return;
notifications.AddNotification(Locs.Notifications_PluginDisabled(plugin.Manifest.Name), Locs.Notifications_PluginDisabledTitle, NotificationType.Success);
});
}
@ -2346,17 +2440,7 @@ internal class PluginInstallerWindow : Window, IDisposable
plugin.ReloadManifest();
}
var enableTask = Task.Run(plugin.Enable)
.ContinueWith(
this.DisplayErrorContinuation,
Locs.ErrorModal_EnableFail(plugin.Name));
enableTask.Wait();
if (!enableTask.Result)
{
this.enableDisableStatus = OperationStatus.Complete;
return;
}
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, true, false);
var loadTask = Task.Run(() => plugin.LoadAsync(PluginLoadReason.Installer))
.ContinueWith(
@ -2409,6 +2493,29 @@ internal class PluginInstallerWindow : Window, IDisposable
// Only if the plugin isn't broken.
this.DrawOpenPluginSettingsButton(plugin);
}
if (applicableForProfiles && config.ProfilesEnabled)
{
ImGui.SameLine();
if (ImGuiComponents.IconButton(FontAwesomeIcon.Toolbox))
{
ImGui.OpenPopup(profileChooserPopupName);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Locs.PluginButtonToolTip_PickProfiles);
}
else if (!applicableForProfiles && config.ProfilesEnabled)
{
ImGui.SameLine();
ImGui.BeginDisabled();
ImGuiComponents.IconButton(FontAwesomeIcon.Toolbox);
ImGui.EndDisabled();
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Locs.PluginButtonToolTip_ProfilesNotSupported);
}
}
private async Task<bool> UpdateSinglePlugin(AvailablePluginUpdate update)
@ -2827,7 +2934,7 @@ internal class PluginInstallerWindow : Window, IDisposable
/// <param name="task">The previous task.</param>
/// <param name="state">An error message to be displayed.</param>
/// <returns>A value indicating whether to continue with the next task.</returns>
private bool DisplayErrorContinuation(Task task, object state)
public bool DisplayErrorContinuation(Task task, object state)
{
if (task.IsFaulted)
{
@ -2911,7 +3018,7 @@ internal class PluginInstallerWindow : Window, IDisposable
#region Header
public static string Header_Hint => Loc.Localize("InstallerHint", "This window allows you to install and remove in-game plugins.\nThey are made by third-party developers.");
public static string Header_Hint => Loc.Localize("InstallerHint", "This window allows you to install and remove Dalamud plugins.\nThey are made by the community.");
public static string Header_SearchPlaceholder => Loc.Localize("InstallerSearch", "Search");
@ -3068,6 +3175,10 @@ internal class PluginInstallerWindow : Window, IDisposable
public static string PluginButtonToolTip_OpenConfiguration => Loc.Localize("InstallerOpenConfig", "Open Configuration");
public static string PluginButtonToolTip_PickProfiles => Loc.Localize("InstallerPickProfiles", "Pick profiles for this plugin");
public static string PluginButtonToolTip_ProfilesNotSupported => Loc.Localize("InstallerProfilesNotSupported", "This plugin does not support profiles");
public static string PluginButtonToolTip_StartOnBoot => Loc.Localize("InstallerStartOnBoot", "Start on boot");
public static string PluginButtonToolTip_AutomaticReloading => Loc.Localize("InstallerAutomaticReloading", "Automatic reloading");
@ -3088,6 +3199,8 @@ internal class PluginInstallerWindow : Window, IDisposable
public static string PluginButtonToolTip_UnloadFailed => Loc.Localize("InstallerUnloadFailedTooltip", "Plugin unload failed, please restart your game and try again.");
public static string PluginButtonToolTip_NeedsToBeInDefault => Loc.Localize("InstallerUnloadNeedsToBeInDefault", "This plugin is in one or more profiles. If you want to enable or disable it, please do so by enabling or disabling one of the profiles it is in.\nIf you want to manage it manually, remove it from all profiles.");
#endregion
#region Notifications

View file

@ -0,0 +1,399 @@
using System;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Raii;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Internal.Profiles;
using ImGuiNET;
using Serilog;
namespace Dalamud.Interface.Internal.Windows.PluginInstaller;
internal class ProfileManagerWidget
{
private readonly PluginInstallerWindow installer;
private Mode mode = Mode.Overview;
private Guid? editingProfileGuid;
private string? pickerSelectedPluginInternalName = null;
private string profileNameEdit = string.Empty;
public ProfileManagerWidget(PluginInstallerWindow installer)
{
this.installer = installer;
}
public void Draw()
{
switch (this.mode)
{
case Mode.Overview:
this.DrawOverview();
break;
case Mode.EditSingleProfile:
this.DrawEdit();
break;
}
}
public void Reset()
{
this.mode = Mode.Overview;
this.editingProfileGuid = null;
this.pickerSelectedPluginInternalName = null;
}
private void DrawOverview()
{
var didAny = false;
var profman = Service<ProfileManager>.Get();
if (ImGuiComponents.IconButton(FontAwesomeIcon.Plus))
profman.AddNewProfile();
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Add a new profile");
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(5);
ImGui.SameLine();
if (ImGuiComponents.IconButton(FontAwesomeIcon.FileImport))
{
try
{
profman.ImportProfile(ImGui.GetClipboardText());
}
catch (Exception ex)
{
Log.Error(ex, "Could not import profile");
}
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Import a shared profile from your clipboard");
ImGui.Separator();
ImGuiHelpers.ScaledDummy(5);
var windowSize = ImGui.GetWindowSize();
if (ImGui.BeginChild("###profileChooserScrolling"))
{
Guid? toCloneGuid = null;
foreach (var profile in profman.Profiles)
{
if (profile.IsDefaultProfile)
continue;
var isEnabled = profile.IsEnabled;
if (ImGuiComponents.ToggleButton($"###toggleButton{profile.Guid}", ref isEnabled))
{
Task.Run(() => profile.SetState(isEnabled))
.ContinueWith(this.installer.DisplayErrorContinuation, "Could not change profile state.");
}
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(3);
ImGui.SameLine();
ImGui.Text(profile.Name);
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30));
if (ImGuiComponents.IconButton($"###editButton{profile.Guid}", FontAwesomeIcon.PencilAlt))
{
this.mode = Mode.EditSingleProfile;
this.editingProfileGuid = profile.Guid;
this.profileNameEdit = profile.Name;
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Edit this profile");
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 2) - 5);
if (ImGuiComponents.IconButton($"###cloneButton{profile.Guid}", FontAwesomeIcon.Copy))
toCloneGuid = profile.Guid;
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Clone this profile");
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 3) - 5);
if (ImGuiComponents.IconButton(FontAwesomeIcon.FileExport))
{
ImGui.SetClipboardText(profile.Model.Serialize());
Service<NotificationManager>.Get().AddNotification("Copied to clipboard!", type: NotificationType.Success);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Copy profile to clipboard for sharing");
didAny = true;
ImGuiHelpers.ScaledDummy(2);
}
if (toCloneGuid != null)
{
profman.CloneProfile(profman.Profiles.First(x => x.Guid == toCloneGuid));
}
if (!didAny)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGuiHelpers.CenteredText("No profiles! Add one!");
ImGui.PopStyleColor();
}
ImGui.EndChild();
}
}
private void DrawEdit()
{
if (this.editingProfileGuid == null)
{
Log.Error("Editing profile guid was null");
this.Reset();
return;
}
var profman = Service<ProfileManager>.Get();
var pm = Service<PluginManager>.Get();
var pic = Service<PluginImageCache>.Get();
var profile = profman.Profiles.FirstOrDefault(x => x.Guid == this.editingProfileGuid);
if (profile == null)
{
Log.Error("Could not find profile {Guid} for edit", this.editingProfileGuid);
this.Reset();
return;
}
const string addPluginToProfilePopup = "###addPluginToProfile";
if (ImGui.BeginPopup(addPluginToProfilePopup))
{
var selected =
pm.InstalledPlugins.FirstOrDefault(
x => x.Manifest.InternalName == this.pickerSelectedPluginInternalName);
if (ImGui.BeginCombo("###pluginPicker", selected == null ? "Pick one" : selected.Manifest.Name))
{
foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles))
{
if (ImGui.Selectable($"{plugin.Manifest.Name}###selector{plugin.Manifest.InternalName}"))
{
this.pickerSelectedPluginInternalName = plugin.Manifest.InternalName;
}
}
ImGui.EndCombo();
}
using (ImRaii.Disabled(this.pickerSelectedPluginInternalName == null))
{
if (ImGui.Button("Do it") && selected != null)
{
Task.Run(() => profile.AddOrUpdate(selected.Manifest.InternalName, selected.IsLoaded));
}
}
ImGui.EndPopup();
}
var didAny = false;
// ======== Top bar ========
var windowSize = ImGui.GetWindowSize();
if (ImGuiComponents.IconButton(FontAwesomeIcon.ArrowLeft))
this.Reset();
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Back to overview");
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(5);
ImGui.SameLine();
if (ImGuiComponents.IconButton(FontAwesomeIcon.FileExport))
{
ImGui.SetClipboardText(profile.Model.Serialize());
Service<NotificationManager>.Get().AddNotification("Copied to clipboard!", type: NotificationType.Success);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Copy profile to clipboard for sharing");
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(5);
ImGui.SameLine();
if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash))
{
Task.Run(() => profman.DeleteProfile(profile))
.ContinueWith(t =>
{
this.Reset();
this.installer.DisplayErrorContinuation(t, "Could not refresh profiles.");
});
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Delete this profile");
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(5);
ImGui.SameLine();
ImGui.SetNextItemWidth(windowSize.X / 3);
if (ImGui.InputText("###profileNameInput", ref this.profileNameEdit, 255))
{
profile.Name = this.profileNameEdit;
}
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGui.GetFrameHeight() * 1.55f * ImGuiHelpers.GlobalScale));
var isEnabled = profile.IsEnabled;
if (ImGuiComponents.ToggleButton($"###toggleButton{profile.Guid}", ref isEnabled))
{
Task.Run(() => profile.SetState(isEnabled))
.ContinueWith(this.installer.DisplayErrorContinuation, "Could not change profile state.");
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Enable/Disable this profile");
ImGui.Separator();
ImGuiHelpers.ScaledDummy(5);
var enableAtBoot = profile.AlwaysEnableAtBoot;
if (ImGui.Checkbox("Always enable when game starts", ref enableAtBoot))
{
profile.AlwaysEnableAtBoot = enableAtBoot;
}
ImGuiHelpers.ScaledDummy(5);
ImGui.Separator();
var wantPluginAddPopup = false;
if (ImGui.BeginChild("###profileEditorPluginList"))
{
var pluginLineHeight = 32 * ImGuiHelpers.GlobalScale;
foreach (var plugin in profile.Plugins)
{
didAny = true;
var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName);
if (pmPlugin != null)
{
pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.Manifest.IsThirdParty, out var icon);
icon ??= pic.DefaultIcon;
ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight));
ImGui.SameLine();
var text = $"{pmPlugin.Name}";
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 = $"{plugin.InternalName} (Not Installed)";
var textHeight = ImGui.CalcTextSize(text);
var before = ImGui.GetCursorPos();
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2));
ImGui.TextUnformatted(text);
var available =
pm.AvailablePlugins.FirstOrDefault(
x => x.InternalName == plugin.InternalName && !x.SourceRepo.IsThirdParty);
if (available != null)
{
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 32 * 2));
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2));
if (ImGuiComponents.IconButton(FontAwesomeIcon.Download))
{
this.installer.StartInstall(available, false);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Install this plugin");
}
ImGui.SetCursorPos(before);
}
ImGui.SameLine();
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30));
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2));
var enabled = plugin.IsEnabled;
if (ImGui.Checkbox($"###{this.editingProfileGuid}-{plugin.InternalName}", ref enabled))
{
Task.Run(() => profile.AddOrUpdate(plugin.InternalName, enabled))
.ContinueWith(this.installer.DisplayErrorContinuation, "Could not change plugin state.");
}
}
if (!didAny)
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "Profile has no plugins!");
}
ImGuiHelpers.ScaledDummy(10);
var addPluginsText = "Add a plugin!";
ImGuiHelpers.CenterCursorFor((int)(ImGui.CalcTextSize(addPluginsText).X + 30 + (ImGuiHelpers.GlobalScale * 5)));
if (ImGuiComponents.IconButton(FontAwesomeIcon.Plus))
wantPluginAddPopup = true;
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(5);
ImGui.SameLine();
ImGui.TextUnformatted(addPluginsText);
ImGuiHelpers.ScaledDummy(10);
ImGui.EndChild();
}
if (wantPluginAddPopup)
ImGui.OpenPopup(addPluginToProfilePopup);
}
private enum Mode
{
Overview,
EditSingleProfile,
}
}

View file

@ -46,6 +46,14 @@ public class SettingsTabExperimental : SettingsTab
new GapSettingsEntry(5, true),
new ThirdRepoSettingsEntry(),
new GapSettingsEntry(5, true),
new SettingsEntry<bool>(
Loc.Localize("DalamudSettingsEnableProfiles", "Enable plugin profiles"),
Loc.Localize("DalamudSettingsEnableProfilesHint", "EXPERIMENTAL: Enables plugin profiles."),
c => c.ProfilesEnabled,
(v, c) => c.ProfilesEnabled = v),
};
public override string Title => Loc.Localize("DalamudSettingsExperimental", "Experimental");

View file

@ -11,6 +11,7 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.Style;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using ImGuiNET;
using Lumina.Excel.GeneratedSheets;
using Serilog;
@ -97,7 +98,7 @@ public class StyleEditorWindow : Window
this.SaveStyle();
var newStyle = StyleModelV1.DalamudStandard;
newStyle.Name = GetRandomName();
newStyle.Name = Util.GetRandomName();
config.SavedStyles.Add(newStyle);
this.currentSel = config.SavedStyles.Count - 1;
@ -167,11 +168,11 @@ public class StyleEditorWindow : Window
{
var newStyle = StyleModel.Deserialize(styleJson);
newStyle.Name ??= GetRandomName();
newStyle.Name ??= Util.GetRandomName();
if (config.SavedStyles.Any(x => x.Name == newStyle.Name))
{
newStyle.Name = $"{newStyle.Name} ({GetRandomName()} Mix)";
newStyle.Name = $"{newStyle.Name} ({Util.GetRandomName()} Mix)";
}
config.SavedStyles.Add(newStyle);
@ -375,15 +376,6 @@ public class StyleEditorWindow : Window
}
}
private static string GetRandomName()
{
var data = Service<DataManager>.Get();
var names = data.GetExcelSheet<BNpcName>(ClientLanguage.English);
var rng = new Random();
return names.ElementAt(rng.Next(0, names.Count() - 1)).Singular.RawString;
}
private void SaveStyle()
{
if (this.currentSel < 2)

View file

@ -23,6 +23,7 @@ using Dalamud.Interface.Internal;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Exceptions;
using Dalamud.Plugin.Internal.Profiles;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Utility;
using Dalamud.Utility.Timing;
@ -77,6 +78,9 @@ Thanks and have fun!";
[ServiceManager.ServiceDependency]
private readonly DalamudStartInfo startInfo = Service<DalamudStartInfo>.Get();
[ServiceManager.ServiceDependency]
private readonly ProfileManager profileManager = Service<ProfileManager>.Get();
[ServiceManager.ServiceConstructor]
private PluginManager()
{
@ -421,7 +425,7 @@ Thanks and have fun!";
try
{
pluginDefs.Add(versionsDefs.OrderByDescending(x => x.Manifest!.EffectiveVersion).First());
pluginDefs.Add(versionsDefs.MaxBy(x => x.Manifest!.EffectiveVersion));
}
catch (Exception ex)
{
@ -839,7 +843,7 @@ Thanks and have fun!";
/// <param name="isBoot">If this plugin is being loaded at boot.</param>
/// <param name="doNotLoad">Don't load the plugin, just don't do it.</param>
/// <returns>The loaded plugin.</returns>
public async Task<LocalPlugin> LoadPluginAsync(FileInfo dllFile, LocalPluginManifest? manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false)
private async Task<LocalPlugin> LoadPluginAsync(FileInfo dllFile, LocalPluginManifest? manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false)
{
var name = manifest?.Name ?? dllFile.Name;
var loadPlugin = !doNotLoad;
@ -859,8 +863,9 @@ Thanks and have fun!";
loadPlugin &= !isBoot || devPlugin.StartOnBoot;
// If we're not loading it, make sure it's disabled
if (!loadPlugin && !devPlugin.IsDisabled)
devPlugin.Disable();
// NOTE: Should be taken care of below by the profile code
// if (!loadPlugin && !devPlugin.IsDisabled)
// devPlugin.Disable();
plugin = devPlugin;
}
@ -870,17 +875,24 @@ Thanks and have fun!";
plugin = new LocalPlugin(dllFile, manifest);
}
#pragma warning disable CS0618
var defaultState = manifest?.Disabled != true && loadPlugin;
#pragma warning restore CS0618
// Need to do this here, so plugins that don't load are still added to the default profile
var wantToLoad = this.profileManager.GetWantState(plugin.Manifest.InternalName, defaultState);
if (loadPlugin)
{
try
{
if (!plugin.IsDisabled && !plugin.IsOrphaned)
if (wantToLoad && !plugin.IsOrphaned)
{
await plugin.LoadAsync(reason);
}
else
{
Log.Verbose($"{name} not loaded, disabled:{plugin.IsDisabled} orphaned:{plugin.IsOrphaned}");
Log.Verbose($"{name} not loaded, wantToLoad:{wantToLoad} orphaned:{plugin.IsOrphaned}");
}
}
catch (InvalidPluginException)
@ -1073,7 +1085,7 @@ Thanks and have fun!";
if (plugin.InstalledPlugin.IsDev)
continue;
if (plugin.InstalledPlugin.Manifest.Disabled && ignoreDisabled)
if (!plugin.InstalledPlugin.IsWantedByAnyProfile && ignoreDisabled)
continue;
if (plugin.InstalledPlugin.Manifest.ScheduledForDeletion)
@ -1132,6 +1144,7 @@ Thanks and have fun!";
if (plugin.IsDev)
{
// TODO: Does this ever work? Why? We should never update devplugins
try
{
plugin.DllFile.Delete();
@ -1151,8 +1164,11 @@ Thanks and have fun!";
{
try
{
// TODO: Why were we ever doing this? We should never be loading the old version in the first place
/*
if (!plugin.IsDisabled)
plugin.Disable();
*/
lock (this.pluginListLock)
{

View file

@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Dalamud.Configuration.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
namespace Dalamud.Plugin.Internal.Profiles;
/// <summary>
/// Class representing a single runtime profile.
/// </summary>
internal class Profile
{
private static readonly ModuleLog Log = new("PROFILE");
private readonly ProfileManager manager;
private readonly ProfileModelV1 modelV1;
/// <summary>
/// Initializes a new instance of the <see cref="Profile"/> class.
/// </summary>
/// <param name="manager">The manager this profile belongs to.</param>
/// <param name="model">The model this profile is tied to.</param>
/// <param name="isDefaultProfile">Whether or not this profile is the default profile.</param>
/// <param name="isBoot">Whether or not this profile was initialized during bootup.</param>
public Profile(ProfileManager manager, ProfileModel model, bool isDefaultProfile, bool isBoot)
{
this.manager = manager;
this.IsDefaultProfile = isDefaultProfile;
this.modelV1 = model as ProfileModelV1 ??
throw new ArgumentException("Model was null or unhandled version");
// We don't actually enable plugins here, PM will do it on bootup
if (isDefaultProfile)
{
// Default profile cannot be disabled
this.IsEnabled = this.modelV1.IsEnabled = true;
this.Name = this.modelV1.Name = "DEFAULT";
}
else if (this.modelV1.AlwaysEnableOnBoot && isBoot)
{
this.IsEnabled = true;
Log.Verbose("{Guid} set enabled because bootup", this.modelV1.Guid);
}
else if (this.modelV1.IsEnabled)
{
this.IsEnabled = true;
Log.Verbose("{Guid} set enabled because remember", this.modelV1.Guid);
}
else
{
Log.Verbose("{Guid} not enabled", this.modelV1.Guid);
}
}
/// <summary>
/// Gets or sets this profile's name.
/// </summary>
public string Name
{
get => this.modelV1.Name;
set
{
this.modelV1.Name = value;
Service<DalamudConfiguration>.Get().QueueSave();
}
}
/// <summary>
/// Gets or sets a value indicating whether this profile shall always be enabled at boot.
/// </summary>
public bool AlwaysEnableAtBoot
{
get => this.modelV1.AlwaysEnableOnBoot;
set
{
this.modelV1.AlwaysEnableOnBoot = value;
Service<DalamudConfiguration>.Get().QueueSave();
}
}
/// <summary>
/// Gets this profile's guid.
/// </summary>
public Guid Guid => this.modelV1.Guid;
/// <summary>
/// Gets a value indicating whether or not this profile is currently enabled.
/// </summary>
public bool IsEnabled { get; private set; }
/// <summary>
/// Gets a value indicating whether or not this profile is the default profile.
/// </summary>
public bool IsDefaultProfile { get; }
/// <summary>
/// Gets all plugins declared in this profile.
/// </summary>
public IEnumerable<ProfilePluginEntry> Plugins =>
this.modelV1.Plugins.Select(x => new ProfilePluginEntry(x.InternalName, x.IsEnabled));
/// <summary>
/// Gets this profile's underlying model.
/// </summary>
public ProfileModel Model => this.modelV1;
/// <summary>
/// Set this profile's state. This cannot be called for the default profile.
/// This will block until all states have been applied.
/// </summary>
/// <param name="enabled">Whether or not the profile is enabled.</param>
/// <param name="apply">Whether or not the current state should immediately be applied.</param>
/// <exception cref="InvalidOperationException">Thrown when an untoggleable profile is toggled.</exception>
public void SetState(bool enabled, bool apply = true)
{
if (this.IsDefaultProfile)
throw new InvalidOperationException("Cannot set state of default profile");
Debug.Assert(this.IsEnabled != enabled, "Trying to set state of a profile to the same state");
this.IsEnabled = this.modelV1.IsEnabled = enabled;
Log.Verbose("Set state {State} for {Guid}", enabled, this.modelV1.Guid);
Service<DalamudConfiguration>.Get().QueueSave();
if (apply)
this.manager.ApplyAllWantStates();
}
/// <summary>
/// Check if this profile contains a specific plugin, and if it is enabled.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <returns>Null if this profile does not declare the plugin, true if the profile declares the plugin and wants it enabled, false if the profile declares the plugin and does not want it enabled.</returns>
public bool? WantsPlugin(string internalName)
{
var entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName);
return entry?.IsEnabled;
}
/// <summary>
/// Add a plugin to this profile with the desired state, or change the state of a plugin in this profile.
/// This will block until all states have been applied.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <param name="state">Whether or not the plugin should be enabled.</param>
/// <param name="apply">Whether or not the current state should immediately be applied.</param>
public void AddOrUpdate(string internalName, bool state, bool apply = true)
{
Debug.Assert(!internalName.IsNullOrEmpty(), "!internalName.IsNullOrEmpty()");
var existing = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName);
if (existing != null)
{
existing.IsEnabled = state;
}
else
{
this.modelV1.Plugins.Add(new ProfileModelV1.ProfileModelV1Plugin
{
InternalName = internalName,
IsEnabled = state,
});
}
// We need to remove this plugin from the default profile, if it declares it.
if (!this.IsDefaultProfile && this.manager.DefaultProfile.WantsPlugin(internalName) != null)
{
this.manager.DefaultProfile.Remove(internalName, false);
}
Service<DalamudConfiguration>.Get().QueueSave();
if (apply)
this.manager.ApplyAllWantStates();
}
/// <summary>
/// Remove a plugin from this profile.
/// This will block until all states have been applied.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <param name="apply">Whether or not the current state should immediately be applied.</param>
public void Remove(string internalName, bool apply = true)
{
var entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName);
if (entry == null)
throw new ArgumentException($"No plugin \"{internalName}\" in profile \"{this.Guid}\"");
if (!this.modelV1.Plugins.Remove(entry))
throw new Exception("Couldn't remove plugin from model collection");
// We need to add this plugin back to the default profile, if we were the last profile to have it.
if (!this.manager.IsInAnyProfile(internalName))
{
if (!this.IsDefaultProfile)
{
this.manager.DefaultProfile.AddOrUpdate(internalName, entry.IsEnabled, false);
}
else
{
throw new Exception("Removed plugin from default profile, but wasn't in any other profile");
}
}
Service<DalamudConfiguration>.Get().QueueSave();
if (apply)
this.manager.ApplyAllWantStates();
}
}

View file

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CheapLoc;
using Dalamud.Game;
using Dalamud.Game.Command;
using Dalamud.Game.Gui;
using Dalamud.Utility;
using Serilog;
namespace Dalamud.Plugin.Internal.Profiles;
[ServiceManager.EarlyLoadedService]
internal class ProfileCommandHandler : IServiceType, IDisposable
{
private readonly CommandManager cmd;
private readonly ProfileManager profileManager;
private readonly ChatGui chat;
private readonly Framework framework;
private Queue<(string, ProfileOp)> queue = new();
/// <summary>
/// Initializes a new instance of the <see cref="ProfileCommandHandler"/> class.
/// </summary>
/// <param name="cmd"></param>
[ServiceManager.ServiceConstructor]
public ProfileCommandHandler(CommandManager cmd, ProfileManager profileManager, ChatGui chat, Framework framework)
{
this.cmd = cmd;
this.profileManager = profileManager;
this.chat = chat;
this.framework = framework;
this.cmd.AddHandler("/xlenableprofile", new CommandInfo(this.OnEnableProfile)
{
HelpMessage = "",
ShowInHelp = true,
});
this.cmd.AddHandler("/xldisableprofile", new CommandInfo(this.OnDisableProfile)
{
HelpMessage = "",
ShowInHelp = true,
});
this.cmd.AddHandler("/xltoggleprofile", new CommandInfo(this.OnToggleProfile)
{
HelpMessage = "",
ShowInHelp = true,
});
this.framework.Update += this.FrameworkOnUpdate;
}
private void FrameworkOnUpdate(Framework framework1)
{
if (this.profileManager.IsBusy)
return;
if (this.queue.TryDequeue(out var op))
{
var profile = this.profileManager.Profiles.FirstOrDefault(x => x.Name == op.Item1);
if (profile == null || profile.IsDefaultProfile)
return;
switch (op.Item2)
{
case ProfileOp.Enable:
if (!profile.IsEnabled)
profile.SetState(true, false);
break;
case ProfileOp.Disable:
if (profile.IsEnabled)
profile.SetState(false, false);
break;
case ProfileOp.Toggle:
profile.SetState(!profile.IsEnabled, false);
break;
default:
throw new ArgumentOutOfRangeException();
}
if (profile.IsEnabled)
{
this.chat.Print(Loc.Localize("ProfileCommandsEnabling", "Enabling profile \"{0}\"...").Format(profile.Name));
}
else
{
this.chat.Print(Loc.Localize("ProfileCommandsDisabling", "Disabling profile \"{0}\"...").Format(profile.Name));
}
Task.Run(() => this.profileManager.ApplyAllWantStates()).ContinueWith(t =>
{
if (!t.IsCompletedSuccessfully && t.Exception != null)
{
Log.Error(t.Exception, "Could not apply profiles through commands");
this.chat.PrintError(Loc.Localize("ProfileCommandsApplyFailed", "Failed to apply all of your profiles. Please check the console for errors."));
}
else
{
this.chat.Print(Loc.Localize("ProfileCommandsApplySuccess", "Profiles applied."));
}
});
}
}
public void Dispose()
{
this.cmd.RemoveHandler("/xlenableprofile");
this.cmd.RemoveHandler("/xldisableprofile");
this.cmd.RemoveHandler("/xltoggleprofile");
this.framework.Update += this.FrameworkOnUpdate;
}
private void OnEnableProfile(string command, string arguments)
{
var name = this.ValidateName(arguments);
if (name == null)
return;
this.queue.Enqueue((name, ProfileOp.Enable));
}
private void OnDisableProfile(string command, string arguments)
{
var name = this.ValidateName(arguments);
if (name == null)
return;
this.queue.Enqueue((name, ProfileOp.Disable));
}
private void OnToggleProfile(string command, string arguments)
{
var name = this.ValidateName(arguments);
if (name == null)
return;
this.queue.Enqueue((name, ProfileOp.Toggle));
}
private string? ValidateName(string arguments)
{
var name = arguments.Replace("\"", string.Empty);
if (this.profileManager.Profiles.All(x => x.Name != name))
{
this.chat.PrintError($"No profile like \"{name}\".");
return null;
}
return name;
}
private enum ProfileOp
{
Enable,
Disable,
Toggle,
}
}

View file

@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CheapLoc;
using Dalamud.Configuration.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
namespace Dalamud.Plugin.Internal.Profiles;
/// <summary>
/// Class responsible for managing plugin profiles.
/// </summary>
[ServiceManager.BlockingEarlyLoadedService]
internal class ProfileManager : IServiceType
{
private static readonly ModuleLog Log = new("PROFMAN");
private readonly DalamudConfiguration config;
private readonly List<Profile> profiles = new();
private volatile bool isBusy = false;
/// <summary>
/// Initializes a new instance of the <see cref="ProfileManager"/> class.
/// </summary>
/// <param name="config">Dalamud config.</param>
[ServiceManager.ServiceConstructor]
public ProfileManager(DalamudConfiguration config)
{
this.config = config;
this.LoadProfilesFromConfigInitially();
}
/// <summary>
/// Gets the default profile.
/// </summary>
public Profile DefaultProfile => this.profiles.First(x => x.IsDefaultProfile);
/// <summary>
/// Gets all profiles, including the default profile.
/// </summary>
public IEnumerable<Profile> Profiles => this.profiles;
/// <summary>
/// Gets a value indicating whether or not the profile manager is busy enabling/disabling plugins.
/// </summary>
public bool IsBusy => this.isBusy;
/// <summary>
/// Check if any enabled profile wants a specific plugin enabled.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <param name="defaultState">The state the plugin shall be in, if it needs to be added.</param>
/// <param name="addIfNotDeclared">Whether or not the plugin should be added to the default preset, if it's not present in any preset.</param>
/// <returns>Whether or not the plugin shall be enabled.</returns>
public bool GetWantState(string internalName, bool defaultState, bool addIfNotDeclared = true)
{
var want = false;
var wasInAnyProfile = false;
foreach (var profile in this.profiles)
{
var state = profile.WantsPlugin(internalName);
Log.Verbose("Checking {Name} in {Profile}: {State}", internalName, profile.Guid, state == null ? "null" : state);
if (state.HasValue)
{
want = want || (profile.IsEnabled && state.Value);
wasInAnyProfile = true;
}
}
if (!wasInAnyProfile && addIfNotDeclared)
{
Log.Warning("{Name} was not in any profile, adding to default with {Default}", internalName, defaultState);
this.DefaultProfile.AddOrUpdate(internalName, defaultState, false);
return defaultState;
}
return want;
}
/// <summary>
/// Check whether a plugin is declared in any profile.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <returns>Whether or not the plugin is in any profile.</returns>
public bool IsInAnyProfile(string internalName)
=> this.profiles.Any(x => x.WantsPlugin(internalName) != null);
/// <summary>
/// Check whether a plugin is only in the default profile.
/// A plugin can never be in the default profile if it is in any other profile.
/// </summary>
/// <param name="internalName">The internal name of the plugin.</param>
/// <returns>Whether or not the plugin is in the default profile.</returns>
public bool IsInDefaultProfile(string internalName)
=> this.DefaultProfile.WantsPlugin(internalName) != null;
/// <summary>
/// Add a new profile.
/// </summary>
/// <returns>The added profile.</returns>
public Profile AddNewProfile()
{
var model = new ProfileModelV1
{
Guid = Guid.NewGuid(),
Name = this.GenerateUniqueProfileName(Loc.Localize("PluginProfilesNewProfile", "New Profile")),
IsEnabled = false,
};
this.config.SavedProfiles!.Add(model);
this.config.QueueSave();
var profile = new Profile(this, model, false, false);
this.profiles.Add(profile);
return profile;
}
/// <summary>
/// Clone a specified profile.
/// </summary>
/// <param name="toClone">The profile to clone.</param>
/// <returns>The newly cloned profile.</returns>
public Profile CloneProfile(Profile toClone)
{
var newProfile = this.ImportProfile(toClone.Model.Serialize());
if (newProfile == null)
throw new Exception("New profile was null while cloning");
return newProfile;
}
/// <summary>
/// Import a profile with a sharing string.
/// </summary>
/// <param name="data">The sharing string to import.</param>
/// <returns>The imported profile, or null, if the string was invalid.</returns>
public Profile? ImportProfile(string data)
{
var newModel = ProfileModel.Deserialize(data);
if (newModel == null)
return null;
newModel.Guid = Guid.NewGuid();
newModel.Name = this.GenerateUniqueProfileName(newModel.Name.IsNullOrEmpty() ? "Unknown Profile" : newModel.Name);
if (newModel is ProfileModelV1 modelV1)
modelV1.IsEnabled = false;
this.config.SavedProfiles!.Add(newModel);
this.config.QueueSave();
var profile = new Profile(this, newModel, false, false);
this.profiles.Add(profile);
return profile;
}
/// <summary>
/// Go through all profiles and plugins, and enable/disable plugins they want active.
/// This will block until all plugins have been loaded/reloaded!
/// </summary>
public void ApplyAllWantStates()
{
this.isBusy = true;
Log.Information("Getting want states...");
var wantActive = this.profiles
.Where(x => x.IsEnabled)
.SelectMany(profile => profile.Plugins.Where(plugin => plugin.IsEnabled)
.Select(plugin => plugin.InternalName))
.Distinct().ToList();
foreach (var internalName in wantActive)
{
Log.Information("\t=> Want {Name}", internalName);
}
Log.Information("Applying want states...");
var pm = Service<PluginManager>.Get();
var tasks = new List<Task>();
foreach (var installedPlugin in pm.InstalledPlugins)
{
if (installedPlugin.IsDev)
continue;
var wantThis = wantActive.Contains(installedPlugin.Manifest.InternalName);
switch (wantThis)
{
case true when !installedPlugin.IsLoaded:
if (installedPlugin.ApplicableForLoad)
{
Log.Information("\t=> Enabling {Name}", installedPlugin.Manifest.InternalName);
tasks.Add(installedPlugin.LoadAsync(PluginLoadReason.Installer));
}
else
{
Log.Warning("\t=> {Name} wanted active, but not applicable", installedPlugin.Manifest.InternalName);
}
break;
case false when installedPlugin.IsLoaded:
Log.Information("\t=> Disabling {Name}", installedPlugin.Manifest.InternalName);
tasks.Add(installedPlugin.UnloadAsync());
break;
}
}
// This is probably not ideal... Might need to rethink the error handling strategy for this.
try
{
Task.WaitAll(tasks.ToArray());
}
catch (Exception e)
{
Log.Error(e, "Couldn't apply state for one or more plugins");
}
Log.Information("Applied!");
this.isBusy = false;
}
/// <summary>
/// Delete a profile and re-apply all profiles.
/// </summary>
/// <param name="profile">The profile to delete.</param>
public void DeleteProfile(Profile profile)
{
Debug.Assert(this.config.SavedProfiles!.Remove(profile.Model), "this.config.SavedProfiles!.Remove(profile.Model)");
Debug.Assert(this.profiles.Remove(profile), "this.profiles.Remove(profile)");
this.ApplyAllWantStates();
this.config.QueueSave();
}
private string GenerateUniqueProfileName(string startingWith)
{
if (this.profiles.All(x => x.Name != startingWith))
return startingWith;
startingWith = Regex.Replace(startingWith, @" \(.* Mix\)", string.Empty);
while (true)
{
var newName = $"{startingWith} ({CultureInfo.InvariantCulture.TextInfo.ToTitleCase(Util.GetRandomName())} Mix)";
if (this.profiles.All(x => x.Name != newName))
return newName;
}
}
private void LoadProfilesFromConfigInitially()
{
var needMigration = false;
if (this.config.DefaultProfile == null)
{
this.config.DefaultProfile = new ProfileModelV1();
needMigration = true;
}
this.profiles.Add(new Profile(this, this.config.DefaultProfile, true, true));
if (needMigration)
{
// Don't think we need this here with the migration logic in GetWantState
//this.MigratePluginsIntoDefaultProfile();
}
this.config.SavedProfiles ??= new List<ProfileModel>();
foreach (var profileModel in this.config.SavedProfiles)
{
this.profiles.Add(new Profile(this, profileModel, false, true));
}
this.config.QueueSave();
}
// This duplicates some of the original handling in PM; don't care though
/*
private void MigratePluginsIntoDefaultProfile()
{
var pluginDirectory = new DirectoryInfo(Service<DalamudStartInfo>.Get().PluginDirectory!);
var pluginDefs = new List<PluginDef>();
Log.Information($"Now migrating plugins at {pluginDirectory} into profiles");
// Nothing to migrate
if (!pluginDirectory.Exists)
{
Log.Information("\t=> Plugin directory didn't exist, nothing to migrate");
return;
}
// Add installed plugins. These are expected to be in a specific format so we can look for exactly that.
foreach (var pluginDir in pluginDirectory.GetDirectories())
{
var versionsDefs = new List<PluginDef>();
foreach (var versionDir in pluginDir.GetDirectories())
{
try
{
var dllFile = new FileInfo(Path.Combine(versionDir.FullName, $"{pluginDir.Name}.dll"));
var manifestFile = LocalPluginManifest.GetManifestFile(dllFile);
if (!manifestFile.Exists)
continue;
var manifest = LocalPluginManifest.Load(manifestFile);
versionsDefs.Add(new PluginDef(dllFile, manifest, false));
}
catch (Exception ex)
{
Log.Error(ex, "Could not load manifest for installed at {Directory}", versionDir.FullName);
}
}
try
{
pluginDefs.Add(versionsDefs.MaxBy(x => x.Manifest!.EffectiveVersion));
}
catch (Exception ex)
{
Log.Error(ex, "Couldn't choose best version for plugin: {Name}", pluginDir.Name);
}
}
var defaultProfile = this.DefaultProfile;
foreach (var pluginDef in pluginDefs)
{
if (pluginDef.Manifest == null)
{
Log.Information($"\t=> Skipping DLL at {pluginDef.DllFile.FullName}, no valid manifest");
continue;
}
// OK for migration code
#pragma warning disable CS0618
defaultProfile.AddOrUpdate(pluginDef.Manifest.InternalName, !pluginDef.Manifest.Disabled, false);
Log.Information(
$"\t=> Added {pluginDef.Manifest.InternalName} to default profile with {!pluginDef.Manifest.Disabled}");
#pragma warning restore CS0618
}
}
*/
}

View file

@ -0,0 +1,40 @@
using System;
using Dalamud.Utility;
using Newtonsoft.Json;
namespace Dalamud.Plugin.Internal.Profiles;
public abstract class ProfileModel
{
[JsonProperty("id")]
public Guid Guid { get; set; } = Guid.Empty;
[JsonProperty("n")]
public string Name { get; set; } = "New Profile";
public static ProfileModel? Deserialize(string model)
{
var json = Util.DecompressString(Convert.FromBase64String(model.Substring(3)));
if (model.StartsWith(ProfileModelV1.SerializedPrefix))
return JsonConvert.DeserializeObject<ProfileModelV1>(json);
throw new ArgumentException("Was not a compressed style model.");
}
public string Serialize()
{
string prefix;
switch (this)
{
case ProfileModelV1:
prefix = ProfileModelV1.SerializedPrefix;
break;
default:
throw new ArgumentOutOfRangeException();
}
return prefix + Convert.ToBase64String(Util.CompressString(JsonConvert.SerializeObject(this)));
}
}

View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Dalamud.Plugin.Internal.Profiles;
public class ProfileModelV1 : ProfileModel
{
public static string SerializedPrefix => "DP1";
[JsonProperty("b")]
public bool AlwaysEnableOnBoot { get; set; } = false;
[JsonProperty("e")]
public bool IsEnabled { get; set; } = false;
[JsonProperty("c")]
public uint Color { get; set; }
public List<ProfileModelV1Plugin> Plugins { get; set; } = new();
public class ProfileModelV1Plugin
{
public string InternalName { get; set; }
public bool IsEnabled { get; set; }
}
}

View file

@ -0,0 +1,14 @@
namespace Dalamud.Plugin.Internal.Profiles;
internal class ProfilePluginEntry
{
public ProfilePluginEntry(string internalName, bool state)
{
this.InternalName = internalName;
this.IsEnabled = state;
}
public string InternalName { get; }
public bool IsEnabled { get; }
}

View file

@ -14,6 +14,7 @@ using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Exceptions;
using Dalamud.Plugin.Internal.Loader;
using Dalamud.Plugin.Internal.Profiles;
using Dalamud.Utility;
namespace Dalamud.Plugin.Internal.Types;
@ -199,10 +200,20 @@ internal class LocalPlugin : IDisposable
/// </summary>
public bool IsLoaded => this.State == PluginState.Loaded;
/*
/// <summary>
/// Gets a value indicating whether the plugin is disabled.
/// </summary>
[Obsolete("This is no longer accurate, use the profile manager instead.", true)]
public bool IsDisabled => this.Manifest.Disabled;
*/
/// <summary>
/// Gets a value indicating whether this plugin is wanted active by any profile.
/// INCLUDES the default profile.
/// </summary>
public bool IsWantedByAnyProfile =>
Service<ProfileManager>.Get().GetWantState(this.Manifest.InternalName, false, false);
/// <summary>
/// Gets a value indicating whether this plugin's API level is out of date.
@ -238,6 +249,12 @@ internal class LocalPlugin : IDisposable
/// </summary>
public bool IsDev => this is LocalDevPlugin;
/// <summary>
/// Gets a value indicating whether this plugin should be allowed to load.
/// </summary>
public bool ApplicableForLoad => !this.IsBanned && !this.IsDecommissioned && !this.IsOrphaned && !this.IsOutdated
&& !(!this.IsDev && this.State == PluginState.UnloadError) && this.CheckPolicy();
/// <inheritdoc/>
public void Dispose()
{
@ -275,7 +292,6 @@ internal class LocalPlugin : IDisposable
/// <returns>A task.</returns>
public async Task LoadAsync(PluginLoadReason reason, bool reloading = false)
{
var configuration = await Service<DalamudConfiguration>.GetAsync();
var framework = await Service<Framework>.GetAsync();
var ioc = await Service<ServiceContainer>.GetAsync();
var pluginManager = await Service<PluginManager>.GetAsync();
@ -297,6 +313,10 @@ internal class LocalPlugin : IDisposable
this.ReloadManifest();
}
// If we reload a plugin we don't want to delete it. Makes sense, right?
this.Manifest.ScheduledForDeletion = false;
this.SaveManifest();
switch (this.State)
{
case PluginState.Loaded:
@ -329,8 +349,9 @@ internal class LocalPlugin : IDisposable
if (this.Manifest.DalamudApiLevel < PluginManager.DalamudApiLevel && !pluginManager.LoadAllApiLevels)
throw new InvalidPluginOperationException($"Unable to load {this.Name}, incompatible API level");
if (this.Manifest.Disabled)
throw new InvalidPluginOperationException($"Unable to load {this.Name}, disabled");
// TODO: should we throw here?
if (!this.IsWantedByAnyProfile)
Log.Warning("{Name} is loading, but isn't wanted by any profile", this.Name);
if (this.IsOrphaned)
throw new InvalidPluginOperationException($"Plugin {this.Name} had no associated repo.");
@ -547,9 +568,11 @@ internal class LocalPlugin : IDisposable
await this.LoadAsync(PluginLoadReason.Reload, true);
}
/*
/// <summary>
/// Revert a disable. Must be unloaded first, does not load.
/// </summary>
[Obsolete("Profile API", true)]
public void Enable()
{
// Allowed: Unloaded, UnloadError
@ -580,6 +603,7 @@ internal class LocalPlugin : IDisposable
this.Manifest.ScheduledForDeletion = false;
this.SaveManifest();
}
*/
/// <summary>
/// Check if anything forbids this plugin from loading.
@ -602,9 +626,11 @@ internal class LocalPlugin : IDisposable
return true;
}
/*
/// <summary>
/// Disable this plugin, must be unloaded first.
/// </summary>
[Obsolete("Profile API", true)]
public void Disable()
{
// Allowed: Unloaded, UnloadError
@ -631,6 +657,7 @@ internal class LocalPlugin : IDisposable
this.Manifest.Disabled = true;
this.SaveManifest();
}
*/
/// <summary>
/// Schedule the deletion of this plugin on next cleanup.
@ -650,9 +677,9 @@ internal class LocalPlugin : IDisposable
var manifest = LocalPluginManifest.GetManifestFile(this.DllFile);
if (manifest.Exists)
{
var isDisabled = this.IsDisabled; // saving the internal state because it could have been deleted
//var isDisabled = this.IsDisabled; // saving the internal state because it could have been deleted
this.Manifest = LocalPluginManifest.Load(manifest);
this.Manifest.Disabled = isDisabled;
//this.Manifest.Disabled = isDisabled;
this.SaveManifest();
}

View file

@ -28,6 +28,7 @@ internal record LocalPluginManifest : PluginManifest
/// Gets or sets a value indicating whether the plugin is disabled and should not be loaded.
/// This value supersedes the ".disabled" file functionality and should not be included in the plugin master.
/// </summary>
[Obsolete("This is merely used for migrations now. Use the profile manager to check if a plugin shall be enabled.")]
public bool Disabled { get; set; }
/// <summary>

View file

@ -162,6 +162,12 @@ internal record PluginManifest
[JsonProperty]
public bool CanUnloadAsync { get; init; }
/// <summary>
/// Gets a value indicating whether the plugin supports profiles.
/// </summary>
[JsonProperty]
public bool SupportsProfiles { get; init; } = true;
/// <summary>
/// Gets a list of screenshot image URLs to show in the plugin installer.
/// </summary>

View file

@ -30,3 +30,5 @@ public enum PluginLoadReason
/// </summary>
Boot,
}
// TODO(api9): This should be a mask, so that we can combine Installer | ProfileLoaded

View file

@ -11,12 +11,14 @@ using System.Runtime.CompilerServices;
using System.Text;
using Dalamud.Configuration.Internal;
using Dalamud.Data;
using Dalamud.Game;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Logging.Internal;
using ImGuiNET;
using Lumina.Excel.GeneratedSheets;
using Microsoft.Win32;
using Serilog;
@ -605,6 +607,19 @@ public static class Util
File.Move(tmpPath, path, true);
}
/// <summary>
/// Gets a random, inoffensive, human-friendly string.
/// </summary>
/// <returns>A random human-friendly name.</returns>
internal static string GetRandomName()
{
var data = Service<DataManager>.Get();
var names = data.GetExcelSheet<BNpcName>(ClientLanguage.English)!;
var rng = new Random();
return names.ElementAt(rng.Next(0, names.Count() - 1)).Singular.RawString;
}
private static unsafe void ShowValue(ulong addr, IEnumerable<string> path, Type type, object value)
{
if (type.IsPointer)