Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Flow.Launcher.Core/Plugin/PluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -23,14 +24,25 @@
var allPluginMetadata = new List<PluginMetadata>();
var directories = pluginDirectories.SelectMany(Directory.EnumerateDirectories);

// todo use linq when diable plugin is implmented since parallel.foreach + list is not thread saft

Check warning on line 27 in Flow.Launcher.Core/Plugin/PluginConfig.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`implmented` is not a recognized word. (unrecognized-spelling)
foreach (var directory in directories)
{
if (File.Exists(Path.Combine(directory, DataLocation.PluginDeleteFile)))
{
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)
{
Expand Down Expand Up @@ -79,7 +91,7 @@
// If metadata's version greater than each duplicate's version, CompareTo > 0
var count = group.Where(x => metadata.Version.CompareTo(x.Version) > 0).Count();

// Only add if the meatadata's version is the highest of all duplicates in the group

Check warning on line 94 in Flow.Launcher.Core/Plugin/PluginConfig.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`meatadata` is not a recognized word. (unrecognized-spelling)
if (count == group.Count() - 1)
{
unique_list.Add(metadata);
Expand Down
12 changes: 12 additions & 0 deletions Flow.Launcher.Core/Plugin/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,18 @@

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 delete mark file", e);

Check failure on line 943 in Flow.Launcher.Core/Plugin/PluginManager.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

` delete delete ` matches a line_forbidden.patterns entry: `\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\s\g{-1}\s`. (forbidden-pattern)
}

try
{
if (Directory.Exists(tempFolderPluginPath))
Expand Down
113 changes: 113 additions & 0 deletions Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@
foreach (FileInfo file in files)
{
string temppath = Path.Combine(targetPath, file.Name);
file.CopyTo(temppath, false);

Check warning on line 51 in Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`temppath` is not a recognized word. (unrecognized-spelling)
}

// Recursively copy subdirectories by calling itself on each subdirectory until there are no more to copy
foreach (DirectoryInfo subdir in dirs)
{
string temppath = Path.Combine(targetPath, subdir.Name);

Check warning on line 57 in Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`temppath` is not a recognized word. (unrecognized-spelling)
CopyAll(subdir.FullName, temppath, messageBoxExShow);

Check warning on line 58 in Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`temppath` is not a recognized word. (unrecognized-spelling)
}
}
catch (Exception)
Expand Down Expand Up @@ -130,6 +130,119 @@
}
}

/// <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>
Expand Down
85 changes: 85 additions & 0 deletions Flow.Launcher.Test/FilesFoldersTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Flow.Launcher.Plugin.SharedCommands;
using NUnit.Framework;
using NUnit.Framework.Legacy;

Check warning on line 3 in Flow.Launcher.Test/FilesFoldersTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`NUnit` is not a recognized word. (unrecognized-spelling)
using System.IO;

namespace Flow.Launcher.Test
{
Expand Down Expand Up @@ -32,8 +33,8 @@
[TestCase(@"c:\foobar\", @"c:\foo\a.txt", false)]
// Edge case
[TestCase(@"c:\foo", @"c:\foo\..\bar\baz", false)]
[TestCase(@"c:\bar", @"c:\foo\..\bar\baz", true)]

Check warning on line 36 in Flow.Launcher.Test/FilesFoldersTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`baz` is not a recognized word. (unrecognized-spelling)
[TestCase(@"c:\barr", @"c:\foo\..\bar\baz", false)]

Check warning on line 37 in Flow.Launcher.Test/FilesFoldersTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`baz` is not a recognized word. (unrecognized-spelling)
public void GivenTwoPaths_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult)
{
ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path));
Expand All @@ -50,5 +51,89 @@
{
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