diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
index 00d70b0e..58aff078 100644
--- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
+++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
@@ -4,6 +4,7 @@ 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;
@@ -134,69 +135,73 @@ public sealed class AssistantPluginInstallService
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;
+ ///
+ /// Installs an assistant plugin archive that contains exactly one plugin.lua file.
+ /// Companion files are validated from and moved with the same staging directory.
+ ///
+ /// The local .mwplugin or .zip archive path.
+ /// Cancellation token for extraction, validation, file IO, and plugin reload.
+ /// Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.
+ public async Task InstallArchiveAsync(string archivePath, CancellationToken token)
+ {
+ 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 this.ValidateAssistantPluginCodeAsync(
+ pluginDirectory,
+ pluginCode.Trim(),
+ TB("The imported plugin is not an assistant plugin. Issue: {0}"),
+ TB("The imported assistant plugin is invalid. Issue: {0}"),
+ TB("The imported assistant plugin uses the ID of an internal AI Studio 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);
+
+ 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();
}
}
@@ -412,6 +417,65 @@ public sealed class AssistantPluginInstallService
}
}
+ private async Task InstallStagedAssistantAsync(string assistantPluginsRoot, AssistantPluginValidationResult validation, CancellationToken token)
+ {
+ var stagingDirectory = validation.StagingDirectory;
+ var assistantPlugin = validation.AssistantPlugin!;
+ string? backupDirectory = null;
+ string? finalDirectory = null;
+ var replacedExisting = 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."));
+
+ if (Directory.Exists(finalDirectory))
+ {
+ replacedExisting = true;
+ backupDirectory = Path.Join(assistantPluginsRoot, $".{Path.GetFileName(finalDirectory)}.backup-{Guid.NewGuid():N}");
+ Directory.Move(finalDirectory, backupDirectory);
+ }
+
+ Directory.Move(stagingDirectory, finalDirectory);
+ 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.");
+
+ if (!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 ValidateIntoStagingAsync(string lua, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(lua))
@@ -706,4 +770,4 @@ public sealed class AssistantPluginInstallService
{
public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);
}
-}
\ No newline at end of file
+}