Skip to content

Commit e74285c

Browse files
authored
Merge pull request #5 from Jack251970/copilot/fix-plugin-directory-deletion
Fix incomplete plugin directory deletion on uninstall
2 parents 6c8add0 + 7b3216b commit e74285c

File tree

3 files changed

+210
-1
lines changed

3 files changed

+210
-1
lines changed

Flow.Launcher.Core/Plugin/PluginConfig.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Flow.Launcher.Plugin;
77
using System.Text.Json;
88
using Flow.Launcher.Infrastructure.UserSettings;
9+
using Flow.Launcher.Plugin.SharedCommands;
910

1011
namespace Flow.Launcher.Core.Plugin
1112
{
@@ -30,7 +31,17 @@ public static List<PluginMetadata> Parse(string[] pluginDirectories)
3031
{
3132
try
3233
{
33-
Directory.Delete(directory, true);
34+
var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100);
35+
if (!fullyDeleted)
36+
{
37+
// Directory was not fully deleted, recreate the marker file so deletion will be retried on next startup
38+
var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile);
39+
if (!File.Exists(markerFilePath))
40+
{
41+
File.WriteAllText(markerFilePath, string.Empty);
42+
}
43+
PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted. Some files or folders may still remain. Deletion will be retried on next startup.");
44+
}
3445
}
3546
catch (Exception e)
3647
{

Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,119 @@ public static void RemoveFolderIfExists(this string path, Func<string, MessageBo
130130
}
131131
}
132132

133+
/// <summary>
134+
/// Attempts to delete a directory robustly with retry logic for locked files.
135+
/// This method tries to delete files individually with retries, then removes empty directories.
136+
/// Returns true if the directory was completely deleted, false if some files/folders remain.
137+
/// </summary>
138+
/// <param name="path">The directory path to delete</param>
139+
/// <param name="maxRetries">Maximum number of retry attempts for locked files (default: 3)</param>
140+
/// <param name="retryDelayMs">Delay in milliseconds between retries (default: 100ms)</param>
141+
/// <returns>True if directory was fully deleted, false if some items remain</returns>
142+
public static bool TryDeleteDirectoryRobust(string path, int maxRetries = 3, int retryDelayMs = 100)
143+
{
144+
if (!Directory.Exists(path))
145+
return true;
146+
147+
bool fullyDeleted = true;
148+
149+
try
150+
{
151+
// First, try to delete all files in the directory tree
152+
var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories);
153+
foreach (var file in files)
154+
{
155+
bool fileDeleted = false;
156+
for (int attempt = 0; attempt <= maxRetries; attempt++)
157+
{
158+
try
159+
{
160+
// Remove read-only attribute if present
161+
var fileInfo = new FileInfo(file);
162+
if (fileInfo.Exists && fileInfo.IsReadOnly)
163+
{
164+
fileInfo.IsReadOnly = false;
165+
}
166+
167+
File.Delete(file);
168+
fileDeleted = true;
169+
break;
170+
}
171+
catch (UnauthorizedAccessException)
172+
{
173+
// File is in use or access denied, wait and retry
174+
if (attempt < maxRetries)
175+
{
176+
System.Threading.Thread.Sleep(retryDelayMs);
177+
}
178+
}
179+
catch (IOException)
180+
{
181+
// File is in use, wait and retry
182+
if (attempt < maxRetries)
183+
{
184+
System.Threading.Thread.Sleep(retryDelayMs);
185+
}
186+
}
187+
catch
188+
{
189+
// Other exceptions, don't retry
190+
break;
191+
}
192+
}
193+
194+
if (!fileDeleted)
195+
{
196+
fullyDeleted = false;
197+
}
198+
}
199+
200+
// Then, try to delete all empty directories (from deepest to shallowest)
201+
var directories = Directory.GetDirectories(path, "*", SearchOption.AllDirectories)
202+
.OrderByDescending(d => d.Length) // Delete deeper directories first
203+
.ToArray();
204+
205+
foreach (var directory in directories)
206+
{
207+
try
208+
{
209+
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
210+
{
211+
Directory.Delete(directory, false);
212+
}
213+
}
214+
catch
215+
{
216+
// If we can't delete an empty directory, mark as not fully deleted
217+
fullyDeleted = false;
218+
}
219+
}
220+
221+
// Finally, try to delete the root directory itself
222+
try
223+
{
224+
if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any())
225+
{
226+
Directory.Delete(path, false);
227+
}
228+
else if (Directory.Exists(path))
229+
{
230+
fullyDeleted = false;
231+
}
232+
}
233+
catch
234+
{
235+
fullyDeleted = false;
236+
}
237+
}
238+
catch
239+
{
240+
fullyDeleted = false;
241+
}
242+
243+
return fullyDeleted;
244+
}
245+
133246
/// <summary>
134247
/// Checks if a directory exists
135248
/// </summary>

Flow.Launcher.Test/FilesFoldersTest.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Flow.Launcher.Plugin.SharedCommands;
22
using NUnit.Framework;
33
using NUnit.Framework.Legacy;
4+
using System.IO;
45

56
namespace Flow.Launcher.Test
67
{
@@ -50,5 +51,89 @@ public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedRe
5051
{
5152
ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult));
5253
}
54+
55+
[Test]
56+
public void TryDeleteDirectoryRobust_WhenDirectoryDoesNotExist_ReturnsTrue()
57+
{
58+
// Arrange
59+
string nonExistentPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
60+
61+
// Act
62+
bool result = FilesFolders.TryDeleteDirectoryRobust(nonExistentPath);
63+
64+
// Assert
65+
ClassicAssert.IsTrue(result);
66+
}
67+
68+
[Test]
69+
public void TryDeleteDirectoryRobust_WhenDirectoryIsEmpty_DeletesSuccessfully()
70+
{
71+
// Arrange
72+
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
73+
Directory.CreateDirectory(tempDir);
74+
75+
// Act
76+
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);
77+
78+
// Assert
79+
ClassicAssert.IsTrue(result);
80+
ClassicAssert.IsFalse(Directory.Exists(tempDir));
81+
}
82+
83+
[Test]
84+
public void TryDeleteDirectoryRobust_WhenDirectoryHasFiles_DeletesSuccessfully()
85+
{
86+
// Arrange
87+
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
88+
Directory.CreateDirectory(tempDir);
89+
File.WriteAllText(Path.Combine(tempDir, "test.txt"), "test content");
90+
91+
// Act
92+
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);
93+
94+
// Assert
95+
ClassicAssert.IsTrue(result);
96+
ClassicAssert.IsFalse(Directory.Exists(tempDir));
97+
}
98+
99+
[Test]
100+
public void TryDeleteDirectoryRobust_WhenDirectoryHasNestedStructure_DeletesSuccessfully()
101+
{
102+
// Arrange
103+
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
104+
Directory.CreateDirectory(tempDir);
105+
string subDir1 = Path.Combine(tempDir, "SubDir1");
106+
string subDir2 = Path.Combine(tempDir, "SubDir2");
107+
Directory.CreateDirectory(subDir1);
108+
Directory.CreateDirectory(subDir2);
109+
File.WriteAllText(Path.Combine(subDir1, "file1.txt"), "content1");
110+
File.WriteAllText(Path.Combine(subDir2, "file2.txt"), "content2");
111+
File.WriteAllText(Path.Combine(tempDir, "root.txt"), "root content");
112+
113+
// Act
114+
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);
115+
116+
// Assert
117+
ClassicAssert.IsTrue(result);
118+
ClassicAssert.IsFalse(Directory.Exists(tempDir));
119+
}
120+
121+
[Test]
122+
public void TryDeleteDirectoryRobust_WhenFileIsReadOnly_RemovesAttributeAndDeletes()
123+
{
124+
// Arrange
125+
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
126+
Directory.CreateDirectory(tempDir);
127+
string filePath = Path.Combine(tempDir, "readonly.txt");
128+
File.WriteAllText(filePath, "readonly content");
129+
File.SetAttributes(filePath, FileAttributes.ReadOnly);
130+
131+
// Act
132+
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);
133+
134+
// Assert
135+
ClassicAssert.IsTrue(result);
136+
ClassicAssert.IsFalse(Directory.Exists(tempDir));
137+
}
53138
}
54139
}

0 commit comments

Comments
 (0)