Fix incomplete plugin directory deletion on uninstall (#4250)
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
Jack Ye 2026-02-21 19:34:53 +08:00 committed by GitHub
parent 918eb0f4a9
commit 118d6e2a73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 223 additions and 1 deletions

View file

@ -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)
{

View file

@ -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))

View file

@ -130,6 +130,119 @@ namespace Flow.Launcher.Plugin.SharedCommands
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="path">The directory path to delete</param>
/// <param name="maxRetries">Maximum number of retry attempts for locked files (default: 3)</param>
/// <param name="retryDelayMs">Delay in milliseconds between retries (default: 100ms)</param>
/// <returns>True if directory was fully deleted, false if some items remain</returns>
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;
}
/// <summary>
/// Checks if a directory exists
/// </summary>

View file

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