diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index 4bf12faffee..db6813deb6b 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -6,6 +6,7 @@ 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 @@ public static List Parse(string[] pluginDirectories) { 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 54712942c28..b808e2a7fbd 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -931,6 +931,18 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c 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 3af57f00d53..cd1ddf983a0 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -130,6 +130,119 @@ public static void RemoveFolderIfExists(this string path, Func + /// 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 2621fc2da1f..a63b59c3989 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 @@ public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedRe { 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)); + } } }