From 96240ea711bb0dc51056601d39b6f6df6f5ac5a8 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Thu, 10 Sep 2026 22:02:27 +0200 Subject: [PATCH] Honor cancellation before plugin install commit point --- src/ui/Logic/Plugins/PluginDownloadService.cs | 43 +++++++------ .../Plugins/PluginDownloadServiceTests.cs | 60 +++++++++++++++++++ 2 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 tests/UI/Logic/Plugins/PluginDownloadServiceTests.cs diff --git a/src/ui/Logic/Plugins/PluginDownloadService.cs b/src/ui/Logic/Plugins/PluginDownloadService.cs index 5105b404406..0689bb8eb16 100644 --- a/src/ui/Logic/Plugins/PluginDownloadService.cs +++ b/src/ui/Logic/Plugins/PluginDownloadService.cs @@ -74,27 +74,10 @@ await Task.Run(() => targetName = SanitizeFolderName(entry.Name); } - // Remove any previously installed copy of the same plugin. var existing = _pluginCatalog.GetPlugins() .FirstOrDefault(p => p.Manifest.Name.Equals(entry.Name, StringComparison.OrdinalIgnoreCase)); - if (existing != null && Directory.Exists(existing.FolderPath)) - { - Directory.Delete(existing.FolderPath, recursive: true); - } - - cancellationToken.ThrowIfCancellationRequested(); - var targetPath = Path.Combine(Se.PluginsFolder, targetName); - if (Directory.Exists(targetPath)) - { - Directory.Delete(targetPath, recursive: true); - } - - // Last chance to abort before the move that publishes the new - // plugin. Cancelling between the deletes above and the move - // would leave the user with no plugin at all. - cancellationToken.ThrowIfCancellationRequested(); - Directory.Move(source, targetPath); + PublishPlugin(source, targetPath, existing?.FolderPath, cancellationToken); }, cancellationToken); } finally @@ -113,6 +96,30 @@ await Task.Run(() => } } + internal static void PublishPlugin( + string source, + string targetPath, + string? existingPluginPath, + CancellationToken cancellationToken) + { + // This is the commit point for the replacement. Honour cancellation before any + // destructive operation; once an installed copy is removed, finish publishing the + // already-downloaded replacement instead of leaving the user with no plugin at all. + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.IsNullOrEmpty(existingPluginPath) && Directory.Exists(existingPluginPath)) + { + Directory.Delete(existingPluginPath, recursive: true); + } + + if (Directory.Exists(targetPath)) + { + Directory.Delete(targetPath, recursive: true); + } + + Directory.Move(source, targetPath); + } + private static string SanitizeFolderName(string name) { var invalid = Path.GetInvalidFileNameChars(); diff --git a/tests/UI/Logic/Plugins/PluginDownloadServiceTests.cs b/tests/UI/Logic/Plugins/PluginDownloadServiceTests.cs new file mode 100644 index 00000000000..e827799c6de --- /dev/null +++ b/tests/UI/Logic/Plugins/PluginDownloadServiceTests.cs @@ -0,0 +1,60 @@ +using Nikse.SubtitleEdit.Logic.Plugins; +using Xunit; + +namespace UITests.Logic.Plugins; + +public class PluginDownloadServiceTests : IDisposable +{ + private readonly string _tempRoot; + + public PluginDownloadServiceTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "PluginPublish_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + } + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + + [Fact] + public void PublishPlugin_CancelledBeforeCommitPoint_PreservesInstalledPlugin() + { + var source = Path.Combine(_tempRoot, "new-plugin"); + var target = Path.Combine(_tempRoot, "installed-plugin"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(source, "new.txt"), "new"); + File.WriteAllText(Path.Combine(target, "old.txt"), "old"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws(() => + PluginDownloadService.PublishPlugin(source, target, target, cts.Token)); + + Assert.True(File.Exists(Path.Combine(target, "old.txt"))); + Assert.True(File.Exists(Path.Combine(source, "new.txt"))); + } + + [Fact] + public void PublishPlugin_NotCancelled_ReplacesInstalledPlugin() + { + var source = Path.Combine(_tempRoot, "new-plugin"); + var target = Path.Combine(_tempRoot, "installed-plugin"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(source, "new.txt"), "new"); + File.WriteAllText(Path.Combine(target, "old.txt"), "old"); + + PluginDownloadService.PublishPlugin(source, target, target, CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.True(File.Exists(Path.Combine(target, "new.txt"))); + Assert.False(File.Exists(Path.Combine(target, "old.txt"))); + } +}