mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 23:38:00 +00:00
Added export and import of plugin archives (#885)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
e7407ce60a
commit
8a9e6aeb87
43 files changed
+2041
-256
No files matched your search
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue, bool Cancelled = false);
|
||||
@@ -4,17 +4,10 @@ using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue);
|
||||
|
||||
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
|
||||
|
||||
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
|
||||
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
|
||||
public sealed class AssistantPluginInstallService
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService));
|
||||
@@ -22,6 +15,7 @@ public sealed class AssistantPluginInstallService
|
||||
private const string PLUGIN_FILE_NAME = "plugin.lua";
|
||||
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
|
||||
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
|
||||
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
|
||||
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
|
||||
|
||||
private readonly ILogger<AssistantPluginInstallService> logger;
|
||||
@@ -31,6 +25,8 @@ public sealed class AssistantPluginInstallService
|
||||
private readonly SemaphoreSlim installSemaphore = new(1, 1);
|
||||
|
||||
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
|
||||
|
||||
private static AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true);
|
||||
|
||||
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
|
||||
|
||||
@@ -38,11 +34,7 @@ public sealed class AssistantPluginInstallService
|
||||
|
||||
private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
|
||||
|
||||
public AssistantPluginInstallService(
|
||||
ILogger<AssistantPluginInstallService> logger,
|
||||
SettingsManager settingsManager,
|
||||
AssistantSessionService assistantSessionService,
|
||||
MediaTranscriptionService mediaTranscriptionService)
|
||||
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger, SettingsManager settingsManager, AssistantSessionService assistantSessionService, MediaTranscriptionService mediaTranscriptionService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.settingsManager = settingsManager;
|
||||
@@ -127,76 +119,104 @@ public sealed class AssistantPluginInstallService
|
||||
return Error(rootIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
AssistantPluginValidationResult validation;
|
||||
try
|
||||
{
|
||||
validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
var validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
Directory.CreateDirectory(assistantPluginsRoot);
|
||||
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
var stagingDirectory = validation.StagingDirectory;
|
||||
var assistantPlugin = validation.AssistantPlugin;
|
||||
string? backupDirectory = null;
|
||||
string? finalDirectory = null;
|
||||
var replacedExisting = false;
|
||||
/// <summary>
|
||||
/// Installs an assistant plugin archive that contains exactly one <c>plugin.lua</c> file.
|
||||
/// Companion files are validated from and moved with the same staging directory.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</param>
|
||||
/// <param name="confirmAsync">
|
||||
/// Asks the user whether the validated archive may be installed. It is called after all checks
|
||||
/// passed and before anything gets written. Returning false aborts the installation.
|
||||
/// </param>
|
||||
/// <param name="token">Cancellation token for extraction, validation, file IO, and plugin reload.</param>
|
||||
/// <returns>Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.</returns>
|
||||
public async Task<AssistantPluginInstallResult> InstallArchiveAsync(string archivePath, Func<PluginImportPreview, Task<bool>> confirmAsync, CancellationToken token)
|
||||
{
|
||||
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins)
|
||||
return Error(TB("Your organization has disabled importing plugins."));
|
||||
|
||||
if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE))
|
||||
return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip."));
|
||||
|
||||
if (!File.Exists(archivePath))
|
||||
return Error(TB("The selected plugin archive does not exist."));
|
||||
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return Error(rootIssue);
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return Error(TB("The plugin system is not initialized yet."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
|
||||
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
|
||||
token.ThrowIfCancellationRequested();
|
||||
PluginArchive.Extract(archivePath, stagingDirectory);
|
||||
|
||||
if (Directory.Exists(finalDirectory))
|
||||
{
|
||||
replacedExisting = true;
|
||||
backupDirectory = Path.Join(assistantPluginsRoot, $".{Path.GetFileName(finalDirectory)}.backup-{Guid.NewGuid():N}");
|
||||
Directory.Move(finalDirectory, backupDirectory);
|
||||
}
|
||||
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
|
||||
if (pluginFiles.Length != 1)
|
||||
return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
|
||||
|
||||
Directory.Move(stagingDirectory, finalDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(backupDirectory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'.");
|
||||
}
|
||||
}
|
||||
var pluginFile = pluginFiles[0];
|
||||
var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
|
||||
var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
|
||||
var validation = await ValidateAssistantPluginCodeAsync(
|
||||
pluginDirectory,
|
||||
pluginCode.Trim(),
|
||||
TB("Currently, only assistant plugins can be imported."),
|
||||
TB("The imported assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The imported assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
await PluginFactory.LoadAll(token);
|
||||
this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'.");
|
||||
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
// A plugin the user imports by hand never comes from a config server. We reject such
|
||||
// archives because AI Studio trusts this self-declared flag: an imported plugin
|
||||
// claiming it would be neither replaceable nor deletable through the user interface:
|
||||
if (validation.AssistantPlugin.IsManagedByConfigServer)
|
||||
return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."));
|
||||
|
||||
// The archive would replace an existing plugin: reject it when that plugin belongs
|
||||
// to the IT department. We check this before asking the user, so that the
|
||||
// confirmation never offers something we would refuse afterwards anyway:
|
||||
var replacementIssue = GetAssistantReplacementIssue(validation.AssistantPlugin.Id);
|
||||
if (!string.IsNullOrEmpty(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
// Everything is validated, but nothing was written yet. This is the point where the
|
||||
// user decides, because the plugin code comes from an untrusted source:
|
||||
if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin)))
|
||||
return CancelledByUser();
|
||||
|
||||
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, token);
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to install assistant plugin.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, finalDirectory);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath);
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
@@ -289,9 +309,10 @@ public sealed class AssistantPluginInstallService
|
||||
Directory.Move(pluginDirectory, backupDirectory);
|
||||
|
||||
wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id);
|
||||
removedAudits = this.settingsManager.ConfigurationData.AssistantPluginAudits
|
||||
.Where(audit => audit.PluginId == plugin.Id)
|
||||
.ToList();
|
||||
removedAudits =
|
||||
[
|
||||
.. this.settingsManager.ConfigurationData.AssistantPluginAudits.Where(audit => audit.PluginId == plugin.Id)
|
||||
];
|
||||
|
||||
if (removedAudits.Count > 0)
|
||||
this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
|
||||
@@ -412,6 +433,79 @@ public sealed class AssistantPluginInstallService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginInstallResult> InstallStagedAssistantAsync(string assistantPluginsRoot, AssistantPluginValidationResult validation, CancellationToken token)
|
||||
{
|
||||
var stagingDirectory = validation.StagingDirectory;
|
||||
var assistantPlugin = validation.AssistantPlugin!;
|
||||
string? backupDirectory = null;
|
||||
string? finalDirectory = null;
|
||||
var replacedExisting = false;
|
||||
var movedIntoPlace = false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(assistantPluginsRoot);
|
||||
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
|
||||
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
|
||||
|
||||
var replacementIssue = GetAssistantReplacementIssue(assistantPlugin.Id);
|
||||
if (!string.IsNullOrWhiteSpace(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
if (Directory.Exists(finalDirectory))
|
||||
{
|
||||
replacedExisting = true;
|
||||
|
||||
// The backup goes to a directory outside the plugin root, so the plugin loader
|
||||
// cannot discover it during the reload below. Otherwise, the previous version
|
||||
// would be loaded a second time, next to the version we are installing:
|
||||
backupDirectory = CreateInstallBackupDirectory(assistantPlugin);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
|
||||
Directory.Move(finalDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagingDirectory, finalDirectory);
|
||||
movedIntoPlace = true;
|
||||
await PluginFactory.LoadAll(token);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory))
|
||||
TryDeleteDirectory(backupDirectory, "assistant plugin backup", this.logger);
|
||||
|
||||
this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory);
|
||||
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to install assistant plugin.");
|
||||
|
||||
// Only remove the target directory when this installation actually moved the plugin
|
||||
// there. Otherwise, when moving the previous plugin into the backup directory failed,
|
||||
// we would delete the still intact previous plugin:
|
||||
if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory))
|
||||
TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, finalDirectory);
|
||||
await PluginFactory.LoadAll(CancellationToken.None);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
|
||||
}
|
||||
}
|
||||
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lua))
|
||||
@@ -429,12 +523,12 @@ public sealed class AssistantPluginInstallService
|
||||
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
|
||||
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
|
||||
|
||||
var validation = await this.ValidateAssistantPluginCodeAsync(
|
||||
var validation = await ValidateAssistantPluginCodeAsync(
|
||||
stagingDirectory,
|
||||
pluginCode,
|
||||
TB("The generated plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The generated assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The generated assistant plugin uses the ID of an internal AI Studio plugin."),
|
||||
TB("The generated assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
@@ -460,12 +554,12 @@ public sealed class AssistantPluginInstallService
|
||||
|
||||
try
|
||||
{
|
||||
return await this.ValidateAssistantPluginCodeAsync(
|
||||
return await ValidateAssistantPluginCodeAsync(
|
||||
pluginDirectory,
|
||||
lua.Trim(),
|
||||
TB("The edited plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The edited assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The edited assistant plugin uses the ID of an internal AI Studio plugin."),
|
||||
TB("The edited assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -475,23 +569,25 @@ public sealed class AssistantPluginInstallService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(
|
||||
string pluginDirectory,
|
||||
string pluginCode,
|
||||
string notAssistantIssue,
|
||||
string invalidAssistantIssue,
|
||||
string internalPluginIdIssue,
|
||||
CancellationToken token)
|
||||
private static async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(string pluginDirectory, string pluginCode,
|
||||
string notAssistantIssue, string invalidAssistantIssue, string conflictingPluginIdIssue, CancellationToken token)
|
||||
{
|
||||
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token);
|
||||
// The plugin is not installed yet: it sits in a staging directory outside the installed
|
||||
// plugins directory. We allow that directory as the module base, so the plugin can load its
|
||||
// own Lua modules, e.g., an icon.lua, while we validate it:
|
||||
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory);
|
||||
if (plugin is not PluginAssistants assistantPlugin)
|
||||
return AssistantPluginValidationResult.Failure(string.Format(notAssistantIssue, string.Join("; ", plugin.Issues)));
|
||||
|
||||
if (!assistantPlugin.IsValid)
|
||||
return AssistantPluginValidationResult.Failure(string.Format(invalidAssistantIssue, string.Join("; ", assistantPlugin.Issues)));
|
||||
|
||||
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal))
|
||||
return AssistantPluginValidationResult.Failure(internalPluginIdIssue);
|
||||
// Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its
|
||||
// ID alone, e.g., the base language plugin in PluginFactory.Starting. An assistant plugin
|
||||
// carrying the ID of a language or configuration plugin would break those lookups. Reusing
|
||||
// the ID of another local assistant plugin stays allowed: that is how updating one works.
|
||||
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == assistantPlugin.Id && (availablePlugin.IsInternal || availablePlugin.Type is not PluginType.ASSISTANT)))
|
||||
return AssistantPluginValidationResult.Failure(conflictingPluginIdIssue);
|
||||
|
||||
return new(true, string.Empty, assistantPlugin, string.Empty);
|
||||
}
|
||||
@@ -547,22 +643,63 @@ public sealed class AssistantPluginInstallService
|
||||
: TB("The assistant plugin directory does not exist.");
|
||||
}
|
||||
|
||||
private void TryDeleteStagingDirectory(string stagingDirectory)
|
||||
{
|
||||
TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
|
||||
}
|
||||
|
||||
private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
|
||||
|
||||
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
|
||||
{
|
||||
var existingPlugin = PluginFactory.AvailablePlugins
|
||||
.OfType<IAvailablePlugin>()
|
||||
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && !plugin.IsInternal);
|
||||
|
||||
var existingPlugin = FindReplaceableAssistantPlugin(assistantPlugin.Id);
|
||||
return existingPlugin is not null
|
||||
? existingPlugin.LocalPath
|
||||
: Path.Join(assistantPluginsRoot, CreatePluginDirectoryName(assistantPlugin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the local assistant plugin that an installation with the given ID would replace.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
|
||||
/// <returns>The plugin that would be replaced, or null when the installation adds a new plugin.</returns>
|
||||
private static IAvailablePlugin? FindReplaceableAssistantPlugin(Guid pluginId) => PluginFactory.AvailablePlugins
|
||||
.OfType<IAvailablePlugin>()
|
||||
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == pluginId && !plugin.IsInternal);
|
||||
|
||||
/// <summary>
|
||||
/// Collects the metadata an archive declares about itself, together with the information about
|
||||
/// the installed plugin it would replace.
|
||||
/// </summary>
|
||||
/// <param name="assistantPlugin">The validated assistant plugin from the archive.</param>
|
||||
/// <returns>The preview shown to the user before the installation starts.</returns>
|
||||
private static PluginImportPreview CreateImportPreview(PluginAssistants assistantPlugin) => new(assistantPlugin, FindReplaceableAssistantPlugin(assistantPlugin.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an installation may replace the assistant plugin that currently uses the given ID.
|
||||
/// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor
|
||||
/// the Assistant Builder may overwrite them.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
|
||||
/// <returns>A user-facing issue when the existing plugin must not be replaced, an empty string otherwise.</returns>
|
||||
private static string GetAssistantReplacementIssue(Guid pluginId)
|
||||
{
|
||||
var existingPlugin = FindReplaceableAssistantPlugin(pluginId);
|
||||
if (existingPlugin is null)
|
||||
return string.Empty;
|
||||
|
||||
if (existingPlugin.IsManagedByConfigServer)
|
||||
return TB("Config server managed assistant plugins cannot be replaced.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath))
|
||||
return string.Empty;
|
||||
|
||||
// The metadata above and the running plugin read the same Lua field. We check both, though,
|
||||
// just like the deletion path does:
|
||||
var runningPlugin = PluginFactory.RunningPlugins
|
||||
.OfType<PluginAssistants>()
|
||||
.FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath));
|
||||
|
||||
return runningPlugin?.IsManagedByConfigServer is true
|
||||
? TB("Config server managed assistant plugins cannot be replaced.")
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePluginDirectoryName(PluginAssistants assistantPlugin)
|
||||
{
|
||||
var safeName = CreateSafeDirectoryNamePart(assistantPlugin.Name);
|
||||
@@ -628,6 +765,12 @@ public sealed class AssistantPluginInstallService
|
||||
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private static string CreateInstallBackupDirectory(IPluginMetadata plugin)
|
||||
{
|
||||
var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY);
|
||||
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List<PluginAssistantAudit> removedAudits, CancellationToken token)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class NativeShareService(RustService rustService)
|
||||
{
|
||||
public Task<bool> Share(string filePath) => rustService.ShareFile(filePath);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What the user gets to see before a plugin archive is installed. It is not bound to a specific
|
||||
/// plugin type, so it also serves upcoming import paths for other plugin types.
|
||||
/// </summary>
|
||||
/// <param name="Plugin">The plugin from the archive, with the metadata it declares about itself.</param>
|
||||
/// <param name="ExistingPlugin">The installed plugin that gets replaced or null when the archive adds a new plugin.</param>
|
||||
public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when an installed plugin with the same ID gets replaced.
|
||||
/// </summary>
|
||||
public bool ReplacesExisting => this.ExistingPlugin is not null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.IO.Compression;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger<PluginShareService> logger)
|
||||
{
|
||||
private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue);
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService));
|
||||
|
||||
private const string PLUGIN_FILE_NAME = "plugin.lua";
|
||||
|
||||
/// <remarks>
|
||||
/// Keep in sync with SHARE_DIRECTORY_NAME in runtime/src/share_sheet.rs: the runtime only hands
|
||||
/// archives from a directory with this name to the native share sheet.
|
||||
/// </remarks>
|
||||
private const string TEMPORARY_ARCHIVE_DIRECTORY = "mindwork-ai-studio-plugin-shares";
|
||||
|
||||
private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24;
|
||||
private const int FILE_NAME_PREFIX_MAX_LEN = 80;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shareable plugin archive from a local plugin and hands it over to the user.
|
||||
/// The archive contains the plugin root contents, so <c>plugin.lua</c> is located at the archive root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On Windows and macOS, the archive is created in a temporary directory and handed over to the
|
||||
/// native share sheet. Linux has no such share sheet, since the XDG desktop portals do not provide
|
||||
/// a share interface. Thus, the archive is exported to a location of the user's choice there.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">The local plugin to archive and share.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>The share result, including the archive path when successful.</returns>
|
||||
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, CancellationToken token)
|
||||
{
|
||||
if (plugin.IsInternal)
|
||||
return ShareError(plugin, TB("Internal plugins cannot be shared."));
|
||||
|
||||
if (plugin.IsManagedByConfigServer)
|
||||
return ShareError(plugin, TB("Config Server managed plugins cannot be shared."));
|
||||
|
||||
if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins)
|
||||
return ShareError(plugin, TB("Your organization has disabled sharing plugins."));
|
||||
|
||||
if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue))
|
||||
return ShareError(plugin, issue);
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
return await this.ExportAsync(plugin, pluginRoot, token);
|
||||
|
||||
return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the user for a target location and writes the plugin archive to it.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The local plugin to archive.</param>
|
||||
/// <param name="pluginRoot">The validated plugin root directory.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>The share result, including the chosen archive path when successful.</returns>
|
||||
private async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
|
||||
{
|
||||
var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}";
|
||||
var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName);
|
||||
if (saveResponse.UserCancelled)
|
||||
return new(false, plugin.Name, string.Empty, string.Empty, true);
|
||||
|
||||
var archivePath = saveResponse.SaveFilePath;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
await Task.Run(() =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// The save dialog already asked the user about overwriting an existing file.
|
||||
// ZipFile.CreateFromDirectory would fail on an existing file, though:
|
||||
if (File.Exists(archivePath))
|
||||
File.Delete(archivePath);
|
||||
|
||||
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
|
||||
}, token);
|
||||
|
||||
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
|
||||
return new(true, plugin.Name, archivePath, string.Empty);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
logger.LogError(exception, "Failed to export plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
|
||||
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the plugin archive in a temporary directory and opens the native share sheet for it.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The local plugin to archive and share.</param>
|
||||
/// <param name="pluginRoot">The validated plugin root directory.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>The share result, including the retained temporary archive path when successful.</returns>
|
||||
private async Task<PluginShareResult> ShareViaNativeSheetAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
|
||||
{
|
||||
var archiveDirectory = Path.Join(Path.GetTempPath(), TEMPORARY_ARCHIVE_DIRECTORY);
|
||||
var archivePath = Path.Join(archiveDirectory, $"{CreateSafeFileNamePrefix(plugin.Name)}-{plugin.Id:N}-{Guid.NewGuid():N}{PluginArchive.PLUGIN_FILE_EXTENSION}");
|
||||
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Directory.CreateDirectory(archiveDirectory);
|
||||
this.CleanUpExpiredArchives(archiveDirectory);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
|
||||
}, token);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!await nativeShareService.Share(archivePath))
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
return ShareError(plugin, TB("The native share dialog could not be opened."));
|
||||
}
|
||||
|
||||
logger.LogInformation("Created plugin archive '{ArchivePath}' for plugin '{PluginName}' ({PluginId}).", archivePath, plugin.Name, plugin.Id);
|
||||
return new(true, plugin.Name, archivePath, string.Empty);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
logger.LogError(exception, "Failed to create a share archive for plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
|
||||
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetPluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue)
|
||||
{
|
||||
pluginRoot = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
{
|
||||
issue = TB("The plugin has no local directory.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pluginRoot = Path.GetFullPath(plugin.LocalPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
issue = string.Format(TB("The plugin directory is invalid: {0}"), exception.Message);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(pluginRoot))
|
||||
{
|
||||
issue = TB("The plugin directory does not exist.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var pluginFile = Path.Join(pluginRoot, PLUGIN_FILE_NAME);
|
||||
if (!IsPathInsideDirectory(pluginRoot, pluginFile) || !File.Exists(pluginFile))
|
||||
{
|
||||
issue = TB("The plugin directory does not contain a plugin.lua file.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CleanUpExpiredArchives(string archiveDirectory)
|
||||
{
|
||||
var expiry = DateTime.UtcNow.AddHours(-TEMPORARY_ARCHIVE_RETENTION_HOURS);
|
||||
foreach (var archivePath in Directory.EnumerateFiles(archiveDirectory, $"*{PluginArchive.PLUGIN_FILE_EXTENSION}", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.GetLastWriteTimeUtc(archivePath) < expiry)
|
||||
File.Delete(archivePath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to delete expired plugin archive '{ArchivePath}'.", archivePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSafeFileNamePrefix(string pluginName)
|
||||
{
|
||||
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
|
||||
var fileName = new string(pluginName
|
||||
.Trim()
|
||||
.Select(character => char.IsLetterOrDigit(character) || ((character is '-' or '_' or '.') && !invalidCharacters.Contains(character))
|
||||
? character
|
||||
: '-')
|
||||
.ToArray())
|
||||
.Trim('-', '.');
|
||||
|
||||
if (fileName.Length > FILE_NAME_PREFIX_MAX_LEN)
|
||||
fileName = fileName[..FILE_NAME_PREFIX_MAX_LEN].Trim('-', '.');
|
||||
|
||||
return string.IsNullOrWhiteSpace(fileName) ? "plugin" : fileName;
|
||||
}
|
||||
|
||||
private static bool IsPathInsideDirectory(string parentDirectory, string path)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void TryDeleteArchive(string archivePath)
|
||||
{
|
||||
if (!File.Exists(archivePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(archivePath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to delete temporary plugin archive '{ArchivePath}'.", archivePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class RustService
|
||||
{
|
||||
public async Task<bool> ShareFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), this.jsonRustSerializerOptions);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ShareFileResponse>(this.jsonRustSerializerOptions);
|
||||
if (result?.Success == true)
|
||||
return true;
|
||||
|
||||
this.logger?.LogError($"The native share sheet could not be opened: {result?.Issue ?? "Unknown error"}");
|
||||
return false;
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
this.logger?.LogWarning(exception, "Failed to reach the Rust runtime share endpoint.");
|
||||
return false;
|
||||
}
|
||||
catch (TaskCanceledException exception)
|
||||
{
|
||||
this.logger?.LogWarning(exception, "Timed out while reaching the Rust runtime share endpoint.");
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.logger?.LogError(exception, "Failed to process the Rust runtime share response.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ShareFileRequest(string FilePath);
|
||||
|
||||
private sealed record ShareFileResponse(bool Success, string Issue);
|
||||
}
|
||||
Reference in new issue
Block a user