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));
+ }
}
}