mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Fix incomplete plugin directory deletion on uninstall (#4250)
Some checks failed
Build / build (push) Has been cancelled
Some checks failed
Build / build (push) Has been cancelled
This commit is contained in:
parent
918eb0f4a9
commit
118d6e2a73
4 changed files with 223 additions and 1 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue