From 118d6e2a73b6ae5cb87c8c198a7d103b32e29e3b Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Sat, 21 Feb 2026 19:34:53 +0800 Subject: [PATCH] Fix incomplete plugin directory deletion on uninstall (#4250) --- Flow.Launcher.Core/Plugin/PluginConfig.cs | 14 ++- Flow.Launcher.Core/Plugin/PluginManager.cs | 12 ++ .../SharedCommands/FilesFolders.cs | 113 ++++++++++++++++++ Flow.Launcher.Test/FilesFoldersTest.cs | 85 +++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index 4bf12faff..db6813deb 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -6,6 +6,7 @@ using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using System.Text.Json; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Core.Plugin { @@ -30,7 +31,18 @@ namespace Flow.Launcher.Core.Plugin { try { - Directory.Delete(directory, true); + var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 200); + if (!fullyDeleted) + { + PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted."); + + // Directory was not fully deleted, recreate the marker file so deletion will be retried on next startup + var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile); + if (!File.Exists(markerFilePath)) + { + File.WriteAllText(markerFilePath, string.Empty); + } + } } catch (Exception e) { diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 54712942c..b808e2a7f 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -931,6 +931,18 @@ namespace Flow.Launcher.Core.Plugin FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s)); + // Check if marker file exists and delete it + try + { + var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile); + if (File.Exists(markerFilePath)) + File.Delete(markerFilePath); + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e); + } + try { if (Directory.Exists(tempFolderPluginPath)) diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index 3af57f00d..cd1ddf983 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -130,6 +130,119 @@ namespace Flow.Launcher.Plugin.SharedCommands } } + /// + /// Attempts to delete a directory robustly with retry logic for locked files. + /// This method tries to delete files individually with retries, then removes empty directories. + /// Returns true if the directory was completely deleted, false if some files/folders remain. + /// + /// The directory path to delete + /// Maximum number of retry attempts for locked files (default: 3) + /// Delay in milliseconds between retries (default: 100ms) + /// True if directory was fully deleted, false if some items remain + public static bool TryDeleteDirectoryRobust(string path, int maxRetries = 3, int retryDelayMs = 100) + { + if (!Directory.Exists(path)) + return true; + + bool fullyDeleted = true; + + try + { + // First, try to delete all files in the directory tree + var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); + foreach (var file in files) + { + bool fileDeleted = false; + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + // Remove read-only attribute if present + var fileInfo = new FileInfo(file); + if (fileInfo.Exists && fileInfo.IsReadOnly) + { + fileInfo.IsReadOnly = false; + } + + File.Delete(file); + fileDeleted = true; + break; + } + catch (UnauthorizedAccessException) + { + // File is in use or access denied, wait and retry + if (attempt < maxRetries) + { + System.Threading.Thread.Sleep(retryDelayMs); + } + } + catch (IOException) + { + // File is in use, wait and retry + if (attempt < maxRetries) + { + System.Threading.Thread.Sleep(retryDelayMs); + } + } + catch + { + // Other exceptions, don't retry + break; + } + } + + if (!fileDeleted) + { + fullyDeleted = false; + } + } + + // Then, try to delete all empty directories (from deepest to shallowest) + var directories = Directory.GetDirectories(path, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length) // Delete deeper directories first + .ToArray(); + + foreach (var directory in directories) + { + try + { + if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory, false); + } + } + catch + { + // If we can't delete an empty directory, mark as not fully deleted + fullyDeleted = false; + } + } + + // Finally, try to delete the root directory itself + try + { + if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) + { + Directory.Delete(path, false); + } + else if (Directory.Exists(path)) + { + fullyDeleted = false; + } + } + catch + { + fullyDeleted = false; + } + } + catch + { + fullyDeleted = false; + } + + return fullyDeleted; + } + /// /// Checks if a directory exists /// diff --git a/Flow.Launcher.Test/FilesFoldersTest.cs b/Flow.Launcher.Test/FilesFoldersTest.cs index 2621fc2da..a63b59c39 100644 --- a/Flow.Launcher.Test/FilesFoldersTest.cs +++ b/Flow.Launcher.Test/FilesFoldersTest.cs @@ -1,6 +1,7 @@ using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; using NUnit.Framework.Legacy; +using System.IO; namespace Flow.Launcher.Test { @@ -50,5 +51,89 @@ namespace Flow.Launcher.Test { ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult)); } + + [Test] + public void TryDeleteDirectoryRobust_WhenDirectoryDoesNotExist_ReturnsTrue() + { + // Arrange + string nonExistentPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + // Act + bool result = FilesFolders.TryDeleteDirectoryRobust(nonExistentPath); + + // Assert + ClassicAssert.IsTrue(result); + } + + [Test] + public void TryDeleteDirectoryRobust_WhenDirectoryIsEmpty_DeletesSuccessfully() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + // Act + bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir); + + // Assert + ClassicAssert.IsTrue(result); + ClassicAssert.IsFalse(Directory.Exists(tempDir)); + } + + [Test] + public void TryDeleteDirectoryRobust_WhenDirectoryHasFiles_DeletesSuccessfully() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + File.WriteAllText(Path.Combine(tempDir, "test.txt"), "test content"); + + // Act + bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir); + + // Assert + ClassicAssert.IsTrue(result); + ClassicAssert.IsFalse(Directory.Exists(tempDir)); + } + + [Test] + public void TryDeleteDirectoryRobust_WhenDirectoryHasNestedStructure_DeletesSuccessfully() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + string subDir1 = Path.Combine(tempDir, "SubDir1"); + string subDir2 = Path.Combine(tempDir, "SubDir2"); + Directory.CreateDirectory(subDir1); + Directory.CreateDirectory(subDir2); + File.WriteAllText(Path.Combine(subDir1, "file1.txt"), "content1"); + File.WriteAllText(Path.Combine(subDir2, "file2.txt"), "content2"); + File.WriteAllText(Path.Combine(tempDir, "root.txt"), "root content"); + + // Act + bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir); + + // Assert + ClassicAssert.IsTrue(result); + ClassicAssert.IsFalse(Directory.Exists(tempDir)); + } + + [Test] + public void TryDeleteDirectoryRobust_WhenFileIsReadOnly_RemovesAttributeAndDeletes() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + string filePath = Path.Combine(tempDir, "readonly.txt"); + File.WriteAllText(filePath, "readonly content"); + File.SetAttributes(filePath, FileAttributes.ReadOnly); + + // Act + bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir); + + // Assert + ClassicAssert.IsTrue(result); + ClassicAssert.IsFalse(Directory.Exists(tempDir)); + } } }