refactor AssistantPluginInstallService to extract installation logic into reusable InstallStagedAssistantAsync method and improve error handling for plugin archive installations

This commit is contained in:
nilsk 2026-07-21 17:03:53 +02:00
parent ef367bf810
commit 92c424333b
No known key found for this signature in database
GPG Key ID: A5C0151B4DDB172C

View File

@ -4,6 +4,7 @@ using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services; namespace AIStudio.Tools.Services;
@ -134,69 +135,73 @@ public sealed class AssistantPluginInstallService
if (!validation.Success || validation.AssistantPlugin is null) if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue); return Error(validation.Issue);
Directory.CreateDirectory(assistantPluginsRoot); return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation, token);
}
finally
{
this.installSemaphore.Release();
}
}
var stagingDirectory = validation.StagingDirectory; /// <summary>
var assistantPlugin = validation.AssistantPlugin; /// Installs an assistant plugin archive that contains exactly one <c>plugin.lua</c> file.
string? backupDirectory = null; /// Companion files are validated from and moved with the same staging directory.
string? finalDirectory = null; /// </summary>
var replacedExisting = false; /// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</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, 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 try
{ {
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin); token.ThrowIfCancellationRequested();
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) PluginArchive.Extract(archivePath, stagingDirectory);
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
if (Directory.Exists(finalDirectory)) var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
{ if (pluginFiles.Length != 1)
replacedExisting = true; return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
backupDirectory = Path.Join(assistantPluginsRoot, $".{Path.GetFileName(finalDirectory)}.backup-{Guid.NewGuid():N}");
Directory.Move(finalDirectory, backupDirectory);
}
Directory.Move(stagingDirectory, finalDirectory); var pluginFile = pluginFiles[0];
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory)) var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
{ var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
try var validation = await this.ValidateAssistantPluginCodeAsync(
{ pluginDirectory,
Directory.Delete(backupDirectory, true); pluginCode.Trim(),
} TB("The imported plugin is not an assistant plugin. Issue: {0}"),
catch (Exception e) TB("The imported assistant plugin is invalid. Issue: {0}"),
{ TB("The imported assistant plugin uses the ID of an internal AI Studio plugin."),
this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'."); token);
}
}
await PluginFactory.LoadAll(token); if (!validation.Success || validation.AssistantPlugin is null)
this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'."); return Error(validation.Issue);
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
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."); this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath);
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.");
}
}
return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
} }
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
}
} }
finally finally
{ {
this.TryDeleteStagingDirectory(stagingDirectory);
this.installSemaphore.Release(); this.installSemaphore.Release();
} }
} }
@ -412,6 +417,65 @@ 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;
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<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token) private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
{ {
if (string.IsNullOrWhiteSpace(lua)) if (string.IsNullOrWhiteSpace(lua))
@ -706,4 +770,4 @@ public sealed class AssistantPluginInstallService
{ {
public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue); public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);
} }
} }