using AIStudio.Components; using AIStudio.Agents.AssistantAudit; using AIStudio.Dialogs; using AIStudio.Settings.DataModel; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Pages; public partial class Plugins : MSGComponentBase { private const string GROUP_ENABLED = "Enabled"; private const string GROUP_DISABLED = "Disabled"; private const string GROUP_INTERNAL = "Internal"; private bool isAutoAuditing; private bool isImportingAssistantPlugin; private DataAssistantPluginAudit AssistantPluginAuditSettings => this.SettingsManager.ConfigurationData.AssistantPluginAudit; private TableGroupDefinition groupConfig = null!; [Inject] private IDialogService DialogService { get; init; } = null!; [Inject] private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!; [Inject] private PluginShareService PluginShareService { get; init; } = null!; [Inject] private RustService RustService { get; init; } = null!; [Inject] private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!; private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(Plugins)); private bool isSharingPlugin; private const string IMPORT_ICON = @" "; #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED ]); this.groupConfig = new TableGroupDefinition { Expandable = true, IsInitiallyExpanded = true, Selector = pluginMeta => { if (pluginMeta.IsInternal) return GROUP_INTERNAL; return this.SettingsManager.IsPluginEnabled(pluginMeta) ? GROUP_ENABLED : GROUP_DISABLED; } }; await base.OnInitializedAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await this.TryAutoAuditAssistantsAsync(); } #endregion private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta) { if (this.SettingsManager.IsPluginEnabled(pluginMeta)) { this.SettingsManager.ConfigurationData.EnabledPlugins.Remove(pluginMeta.Id); await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); return; } if (pluginMeta.Type is not PluginType.ASSISTANT) { this.SettingsManager.ConfigurationData.EnabledPlugins.Add(pluginMeta.Id); await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); return; } var assistantPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == pluginMeta.Id); if (assistantPlugin is null) return; var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); if (securityState.RequiresAudit) { await this.OpenAssistantAuditDialogAsync(pluginMeta.Id); return; } if (securityState is { IsBelowMinimum: true, IsBlocked: true }) { var blockedAudit = securityState.Audit; if (blockedAudit is not null) await this.DialogService.ShowMessageBox(this.T("Assistant Audit"), $"{blockedAudit.Level.GetName()}: {blockedAudit.Summary}", this.T("Close")); return; } if (securityState is { IsBelowMinimum: true, CanOverride: true } && !await this.ConfirmActivationBelowMinimumAsync(pluginMeta.Name, securityState.Audit!.Level)) { return; } this.SettingsManager.ConfigurationData.EnabledPlugins.Add(pluginMeta.Id); await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } private async Task OpenAssistantAuditDialogAsync(Guid pluginId) { var parameters = new DialogParameters { { x => x.PluginId, pluginId }, }; var dialog = await this.DialogService.ShowAsync(this.T("Assistant Audit"), parameters, DialogOptions.FULLSCREEN); var result = await dialog.Result; if (result is null || result.Canceled || result.Data is not AssistantPluginAuditDialogResult auditResult) return; if (auditResult.Audit is not null) this.UpsertAuditCard(auditResult.Audit); if (auditResult.ActivatePlugin) this.SettingsManager.ConfigurationData.EnabledPlugins.Add(pluginId); await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } private async Task ConfirmActivationBelowMinimumAsync(string pluginName, AssistantAuditLevel actualLevel) { var dialogParameters = new DialogParameters { { x => x.Message, string.Format( this.T("The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"), pluginName, actualLevel.GetName(), this.AssistantPluginAuditSettings.MinimumLevel.GetName()) }, }; var dialogReference = await this.DialogService.ShowAsync(this.T("Potentially Dangerous Plugin"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; return dialogResult is not null && !dialogResult.Canceled; } private bool IsActivationSwitchDisabled(IPluginMetadata pluginMeta, bool isEnabled) { if (isEnabled || pluginMeta.Type is not PluginType.ASSISTANT) return false; var assistantPlugin = this.TryGetAssistantPlugin(pluginMeta.Id); if (assistantPlugin is null) return false; var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); return securityState is { IsBlocked: true, RequiresAudit: false }; } private string GetActivationTooltip(IPluginMetadata pluginMeta, bool isEnabled) { if (isEnabled) return this.T("Disable plugin"); if (pluginMeta.Type is not PluginType.ASSISTANT) return this.T("Enable plugin"); var assistantPlugin = this.TryGetAssistantPlugin(pluginMeta.Id); if (assistantPlugin is null) return this.T("Enable plugin"); var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); if (securityState.RequiresAudit) return securityState.ActionLabel; return securityState.IsBlocked ? securityState.Description : this.T("Enable plugin"); } // // These methods decide whether an action exists for a plugin at all. They must not depend on // transient state like an ongoing share: they gate the markup, so a transient value would make // the action buttons disappear and reappear. Transient state belongs into the buttons' Disabled. // private static bool CanEditAssistantPlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath); private static bool CanReviseAssistantPlugin(IAvailablePlugin plugin) { var assistantPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == plugin.Id); return plugin is { IsInternal: false, IsManagedByConfigServer: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath) && assistantPlugin?.IsManagedByConfigServer is false; } private static bool CanSharePlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, IsManagedByConfigServer: false } && !string.IsNullOrWhiteSpace(plugin.LocalPath); /// /// Organizations may disable importing plugin archives by using a configuration plugin. /// private bool AllowPluginImport => this.SettingsManager.ConfigurationData.App.AllowUserToImportPlugins; /// /// Organizations may disable sharing and exporting plugins by using a configuration plugin. /// private bool AllowPluginSharing => this.SettingsManager.ConfigurationData.App.AllowUserToSharePlugins; /// /// Linux has no native share sheet, hence the plugin archive is exported to a location of the /// user's choice there. The action must be labeled accordingly. /// private static string SharePluginIcon => OperatingSystem.IsLinux() ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare; private string SharePluginTooltip => OperatingSystem.IsLinux() ? this.T("Export plugin archive") : this.T("Share plugin archive"); private string SharePluginLockText => OperatingSystem.IsLinux() ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins."); private async Task OpenAssistantPluginEditorDialogAsync(IAvailablePlugin plugin) { var parameters = new DialogParameters { { x => x.PluginId, plugin.Id }, { x => x.PluginLocalPath, plugin.LocalPath }, }; var dialogReference = await this.DialogService.ShowAsync(this.T("Edit Assistant Plugin"), parameters, DialogOptions.BLOCKING_FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not AssistantPluginEditorDialogResult result) return; await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Save, string.Format(this.T("The assistant plugin '{0}' has been successfully saved."), result.PluginName))); LOG.LogInformation($"The assistant plugin '{result.PluginName}' ({result.PluginId}) has been successfully updated."); await this.MessageBus.SendMessage(this, Event.PLUGINS_RELOADED); await this.InvokeAsync(this.StateHasChanged); } private async Task OpenAssistantPluginRevisionDialogAsync(IAvailablePlugin plugin) { var parameters = new DialogParameters { { x => x.PluginId, plugin.Id }, { x => x.PluginLocalPath, plugin.LocalPath }, }; var dialogReference = await this.DialogService.ShowAsync(this.T("Revise Assistant Plugin"), parameters, DialogOptions.BLOCKING_FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not AssistantPluginRevisionDialogResult result) return; await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AutoFixHigh, string.Format(this.T("The assistant plugin '{0}' has been successfully revised."), result.PluginName))); LOG.LogInformation($"The assistant plugin '{result.PluginName}' ({result.PluginId}) has been successfully revised."); await this.MessageBus.SendMessage(this, Event.PLUGINS_RELOADED); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); await this.InvokeAsync(this.StateHasChanged); } private async Task SharePluginAsync(IAvailablePlugin plugin) { if (this.isSharingPlugin) return; this.isSharingPlugin = true; // invoke a state change right away to guard action buttons await this.InvokeAsync(this.StateHasChanged); try { var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None); if (shareResult.Cancelled) return; if (!shareResult.Success) { LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'."); await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, OperatingSystem.IsLinux() ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin."))); return; } // On Linux, the user chose the target location, so we confirm where the archive was stored: if (OperatingSystem.IsLinux()) await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath))); } finally { this.isSharingPlugin = false; await this.InvokeAsync(this.StateHasChanged); } } private async Task ImportAssistantPluginAsync() { if (this.isImportingAssistantPlugin) return; if (!this.AllowPluginImport) return; this.isImportingAssistantPlugin = true; await this.InvokeAsync(this.StateHasChanged); try { var selection = await this.RustService.SelectFile(this.T("Import assistant plugin"), [FileTypes.PLUGIN_ARCHIVE]); if (selection.UserCancelled) return; var result = await this.AssistantPluginInstallService.InstallArchiveAsync(selection.SelectedFilePath, this.ConfirmPluginImportAsync, CancellationToken.None); if (result.Cancelled) return; if (!result.Success) { LOG.LogError("Failed to import assistant plugin archive '{ArchivePath}': {Issue}", selection.SelectedFilePath, result.Issue); // The user actively started this import, so we report the reason in a dialog // instead of a snackbar. Refused imports must not be missed: await this.ShowImportRefusedDialogAsync(result.Issue); return; } var message = result.ReplacedExisting ? this.T("Assistant updated.") : this.T("Assistant installed."); await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Extension, message)); await this.MessageBus.SendMessage(this, Event.PLUGINS_RELOADED); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } finally { this.isImportingAssistantPlugin = false; await this.InvokeAsync(this.StateHasChanged); } } /// /// Shows the metadata of a validated plugin archive and asks whether it may be installed. /// /// The metadata the archive declares about itself. /// True when the user confirmed the installation. private async Task ConfirmPluginImportAsync(PluginImportPreview preview) { var dialogParameters = new DialogParameters { { x => x.Preview, preview }, }; var dialogReference = await this.DialogService.ShowAsync(this.T("Install Plugin"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; return dialogResult is { Canceled: false }; } private async Task ShowImportRefusedDialogAsync(string issue) { var dialogParameters = new DialogParameters { { x => x.Message, string.Format(this.T("The assistant plugin could not be imported: {0}"), issue) }, { x => x.Icon, Icons.Material.Filled.ReportProblem }, { x => x.IconColor, Color.Error }, }; var dialogReference = await this.DialogService.ShowAsync(this.T("Import not possible"), dialogParameters, DialogOptions.FULLSCREEN); await dialogReference.Result; } private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); private PluginAssistants? TryGetAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == pluginId); private async Task TryAutoAuditAssistantsAsync() { if (this.isAutoAuditing || !this.AssistantPluginAuditSettings.AutomaticallyAuditAssistants) return; this.isAutoAuditing = true; try { var wasConfigurationChanged = false; var assistantPlugins = PluginFactory.RunningPlugins.OfType().ToList(); foreach (var assistantPlugin in assistantPlugins) { var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); if (!securityState.RequiresAudit) continue; var audit = await this.AssistantPluginAuditService.RunAuditAsync(assistantPlugin); if (audit.Level is AssistantAuditLevel.UNKNOWN) { await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.SettingsSuggest, string.Format(this.T("The automatic security audit for the assistant plugin '{0}' failed. Please run it manually."), assistantPlugin.Name))); continue; } this.UpsertAuditCard(audit); wasConfigurationChanged = true; } if (!wasConfigurationChanged) return; await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } finally { this.isAutoAuditing = false; await this.InvokeAsync(this.StateHasChanged); } } private void UpsertAuditCard(PluginAssistantAudit audit) { var audits = this.SettingsManager.ConfigurationData.AssistantPluginAudits; var existingIndex = audits.FindIndex(x => x.PluginId == audit.PluginId); if (existingIndex >= 0) audits[existingIndex] = audit; else audits.Add(audit); } #region Overrides of MSGComponentBase protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default { switch (triggeredEvent) { case Event.PLUGINS_RELOADED: await this.TryAutoAuditAssistantsAsync(); await this.InvokeAsync(this.StateHasChanged); break; case Event.CONFIGURATION_CHANGED: await this.InvokeAsync(this.StateHasChanged); break; } } #endregion }