diff --git a/claude.md b/claude.md index 2bc507b9..8da23683 100644 --- a/claude.md +++ b/claude.md @@ -711,7 +711,12 @@ the comment there about not caching "nothing staged" asks for. Its one option is "Run at startup", a checked item over PlatformHeads' `XdgAutostart` (`TrayActions.RunAtLogin`), and the settings file is not read: the hot keys are Windows', and target on left and the launch limit are user environment variables, - which .NET sets only on Windows. No purge, which needs a folder picker. The debug view is + which .NET sets only on Windows. Purge asks its folder and its go ahead through a program the + desktop has (`DesktopDialog`: zenity, or kdialog on KDE, and AppleScript's on the macOS head), + as `DesktopNotification` pops a balloon, and is not on the menu where there is none. Each + question waits on the person, so the purge runs on a thread of its own (`DesktopPurge`), and a + file it cannot delete is counted where the Windows head asks about it. What it finds and + deletes is the core's `VerifiedFiles`, which the Windows head's `FilePurger` uses too. The debug view is written beside the logs and opened (`DebugReportFile`). The icon is the Windows head's `.ico`, whose one picture is a 48 pixel, 32 bit bitmap and needs no decoder (`IconPixels`); the core embeds the Windows head's pictures for both heads off Windows (`TrayPictures`). With no diff --git a/docs/mdsource/tray.source.md b/docs/mdsource/tray.source.md index cdfa171f..ed10a618 100644 --- a/docs/mdsource/tray.source.md +++ b/docs/mdsource/tray.source.md @@ -91,7 +91,9 @@ Discard will clear all currently tracked items. It is the same discard as the pe ### Purge verified files -Windows only. Prompts for a directory, and then recursively deletes all `*.verified.*` in that directory. +Prompts for a directory, says how many `*.verified.*` and `*.received.*` files are under it, and once that is confirmed deletes them all. + +On Windows a file that cannot be deleted is asked about, with a choice to retry it, skip it or stop. On macOS and Linux the questions are put by the desktop's own programs: AppleScript's dialogs on macOS, and on Linux zenity's, or kdialog's on KDE. Either one is used where only one is installed, and with neither the item is not on the menu. A file that cannot be deleted there is skipped, and a notification says how many were deleted and what stopped the rest. ### Debug view @@ -188,6 +190,7 @@ The tray does the same there: it tracks pending moves and deletes, holds the inl * Run at startup * [Update](#update) * [Debug view](#debug-view) + * [Purge verified files](#purge-verified-files), on Linux where zenity or kdialog is installed * Open logs * Raise issue @@ -201,7 +204,6 @@ What is not there: * The Options window. "Open on left" and "Max instances to launch" are environment variables, which the window sets for the user on Windows. Elsewhere set [`DiffEngine_TargetOnLeft`](/docs/diff-tool.md#leftright-diff-behavior) or [`DiffEngine_MaxInstances`](/docs/diff-tool.md#using-an-environment-variable) in the environment test runs start in. * The HotKeys, which are registered with Windows. - * [Purge verified files](#purge-verified-files). * The [locked files](#locked-files) prompt, which has nothing to resolve. diff --git a/docs/tray.md b/docs/tray.md index ff01b61f..c70ba151 100644 --- a/docs/tray.md +++ b/docs/tray.md @@ -98,7 +98,9 @@ Discard will clear all currently tracked items. It is the same discard as the pe ### Purge verified files -Windows only. Prompts for a directory, and then recursively deletes all `*.verified.*` in that directory. +Prompts for a directory, says how many `*.verified.*` and `*.received.*` files are under it, and once that is confirmed deletes them all. + +On Windows a file that cannot be deleted is asked about, with a choice to retry it, skip it or stop. On macOS and Linux the questions are put by the desktop's own programs: AppleScript's dialogs on macOS, and on Linux zenity's, or kdialog's on KDE. Either one is used where only one is installed, and with neither the item is not on the menu. A file that cannot be deleted there is skipped, and a notification says how many were deleted and what stopped the rest. ### Debug view @@ -195,6 +197,7 @@ The tray does the same there: it tracks pending moves and deletes, holds the inl * Run at startup * [Update](#update) * [Debug view](#debug-view) + * [Purge verified files](#purge-verified-files), on Linux where zenity or kdialog is installed * Open logs * Raise issue @@ -208,7 +211,6 @@ What is not there: * The Options window. "Open on left" and "Max instances to launch" are environment variables, which the window sets for the user on Windows. Elsewhere set [`DiffEngine_TargetOnLeft`](/docs/diff-tool.md#leftright-diff-behavior) or [`DiffEngine_MaxInstances`](/docs/diff-tool.md#using-an-environment-variable) in the environment test runs start in. * The HotKeys, which are registered with Windows. - * [Purge verified files](#purge-verified-files). * The [locked files](#locked-files) prompt, which has nothing to resolve. diff --git a/src/DiffEngineTray.Core.Tests/DesktopDialogTests.Arguments.verified.txt b/src/DiffEngineTray.Core.Tests/DesktopDialogTests.Arguments.verified.txt new file mode 100644 index 00000000..b6078a06 --- /dev/null +++ b/src/DiffEngineTray.Core.Tests/DesktopDialogTests.Arguments.verified.txt @@ -0,0 +1,11 @@ +osascript + folder: [-e] [on run argv] [-e] [return POSIX path of (choose folder with prompt (item 1 of argv))] [-e] [end run] [The folder to delete every *.verified.* and *.received.* file under] + confirm: [-e] [on run argv] [-e] [display dialog (item 1 of argv) with title (item 2 of argv) buttons {"Cancel", "Delete"} default button "Delete" cancel button "Cancel" with icon caution] [-e] [end run] [Files found: 2, in /home/a & .\nDelete files?] [Purge verified files] + +zenity + folder: [--file-selection] [--directory] [--title=The folder to delete every *.verified.* and *.received.* file under] + confirm: [--question] [--no-markup] [--title=Purge verified files] [--text=Files found: 2, in /home/a & .\nDelete files?] [--ok-label=Delete] [--cancel-label=Cancel] + +kdialog + folder: [--title] [The folder to delete every *.verified.* and *.received.* file under] [--getexistingdirectory] [{Home}] + confirm: [--title] [Purge verified files] [--yes-label] [Delete] [--no-label] [Cancel] [--yesno] [Files found: 2, in /home/a & .\nDelete files?] \ No newline at end of file diff --git a/src/DiffEngineTray.Core.Tests/DesktopDialogTests.cs b/src/DiffEngineTray.Core.Tests/DesktopDialogTests.cs new file mode 100644 index 00000000..5887d9cf --- /dev/null +++ b/src/DiffEngineTray.Core.Tests/DesktopDialogTests.cs @@ -0,0 +1,55 @@ +public class DesktopDialogTests +{ + /// + /// What each program is handed, which is all there is to see of them without a desktop. + /// + [Test] + public async Task Arguments() => + await Verify( + string.Join( + "\n\n", + new[] {DesktopDialog.AppleScript, DesktopDialog.Zenity, DesktopDialog.Kdialog} + .Select(_ => $""" + {_.Program} + folder: {Quoted(_.FolderArguments)} + confirm: {Quoted(_.ConfirmArguments("Files found: 2, in /home/a & .\nDelete files?"))} + """))); + + /// + /// kdialog opens in the home folder, which is whoever runs the tests'. + /// + static string Quoted(IEnumerable arguments) => + string.Join( + " ", + arguments.Select(_ => _ == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ? "[{Home}]" + : $"[{_.Replace("\n", "\\n")}]")); + + [Test] + [Arguments("KDE", "kdialog")] + [Arguments("ubuntu:GNOME", "zenity")] + [Arguments("kde:plasma", "kdialog")] + [Arguments(null, "zenity")] + public async Task EachDesktopsOwnFirst(string? desktop, string expected) => + await Assert.That(DesktopDialog.ForLinux(desktop, _ => true)!.Program).IsEqualTo(expected); + + [Test] + public async Task EitherStandsInForTheOther() + { + await Assert.That(DesktopDialog.ForLinux("GNOME", _ => _ == "kdialog")!.Program).IsEqualTo("kdialog"); + await Assert.That(DesktopDialog.ForLinux("KDE", _ => _ == "zenity")!.Program).IsEqualTo("zenity"); + } + + [Test] + public async Task NoneWhereNeitherIsInstalled() => + await Assert.That(DesktopDialog.ForLinux("GNOME", _ => false)).IsNull(); + + [Test] + [Arguments("/home/a/project\n", "/home/a/project")] + [Arguments("/Users/a/project/\n", "/Users/a/project")] + [Arguments("/\n", "/")] + [Arguments("", null)] + [Arguments("\n", null)] + public async Task TheFolderAsPrinted(string output, string? expected) => + await Assert.That(DesktopDialog.FolderFrom(output)).IsEqualTo(expected); +} diff --git a/src/DiffEngineTray.Core.Tests/DesktopPurgeTests.cs b/src/DiffEngineTray.Core.Tests/DesktopPurgeTests.cs new file mode 100644 index 00000000..0490069f --- /dev/null +++ b/src/DiffEngineTray.Core.Tests/DesktopPurgeTests.cs @@ -0,0 +1,123 @@ +public class DesktopPurgeTests : + IDisposable +{ + [Test] + public async Task DeletesWhatItFindsOnceConfirmed() + { + var kept = Write("Sample.cs"); + Write("Sample.Test.verified.txt"); + Write(Path.Combine("nested", "Sample.Test.received.png")); + string? asked = null; + + DesktopPurge.Run(() => directory, _ => + { + asked = _; + return true; + }, told.Add); + + await Assert.That(Remaining()).IsEquivalentTo([kept]); + await Assert.That(asked!).StartsWith($"Files found: 2, in {directory}."); + await Assert.That(told).IsEquivalentTo([$"Deleted 2 files from {directory}."]); + } + + [Test] + public async Task DeletesNothingWhenNotConfirmed() + { + var verified = Write("Sample.Test.verified.txt"); + + DesktopPurge.Run(() => directory, _ => false, told.Add); + + await Assert.That(Remaining()).IsEquivalentTo([verified]); + await Assert.That(told).IsEmpty(); + } + + [Test] + public async Task AsksNothingMoreWhenNoFolderIsChosen() + { + Write("Sample.Test.verified.txt"); + var asked = false; + + DesktopPurge.Run(() => null, _ => asked = true, told.Add); + + await Assert.That(asked).IsFalse(); + await Assert.That(Remaining()).HasSingleItem(); + await Assert.That(told).IsEmpty(); + } + + [Test] + public async Task SaysSoWhenThereIsNothingToPurge() + { + Write("Sample.cs"); + var asked = false; + + DesktopPurge.Run(() => directory, _ => asked = true, told.Add); + + await Assert.That(asked).IsFalse(); + await Assert.That(told).IsEquivalentTo([$"No *.verified.* or *.received.* files found in {directory}"]); + } + + /// + /// Passed over and counted, and the rest still deleted. Made undeletable the way each platform + /// refuses a delete for: a read only file on Windows, and on Unix a folder that cannot be + /// written, since there a file's own mode says nothing about removing it. + /// + [Test] + public async Task CountsAFileThatCouldNotBeDeleted() + { + Write("Sample.Test.verified.txt"); + var locked = Write(Path.Combine("locked", "Sample.Test.received.txt")); + var lockedDirectory = Path.GetDirectoryName(locked)!; + Refuse(locked, lockedDirectory, true); + try + { + DesktopPurge.Run(() => directory, _ => true, told.Add); + } + finally + { + Refuse(locked, lockedDirectory, false); + } + + await Assert.That(Remaining()).IsEquivalentTo([locked]); + await Assert.That(told).HasSingleItem(); + await Assert.That(told[0]).StartsWith($"Deleted 1 of 2 files from {directory}. 1 could not be deleted, the first being {locked}: "); + } + + [Test] + public async Task ThereIsNoPurgeWithNothingToAskWith() => + // As a bool, since an Action is taken as the thing to run and assert on + await Assert.That(DesktopPurge.For(null, told.Add) is null).IsTrue(); + + static void Refuse(string file, string fileDirectory, bool refuse) + { + if (OperatingSystem.IsWindows()) + { + File.SetAttributes(file, refuse ? FileAttributes.ReadOnly : FileAttributes.Normal); + return; + } + + var mode = UnixFileMode.UserRead | UnixFileMode.UserExecute; + if (!refuse) + { + mode |= UnixFileMode.UserWrite; + } + + File.SetUnixFileMode(fileDirectory, mode); + } + + string Write(string name) + { + var path = Path.Combine(directory, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "content"); + return path; + } + + string[] Remaining() => + Directory.GetFiles(directory, "*", SearchOption.AllDirectories); + + string directory = Directory.CreateTempSubdirectory("DesktopPurgeTests").FullName; + List told = []; + + public void Dispose() => + Directory.Delete(directory, true); +} diff --git a/src/DiffEngineTray.Core.Tests/TrayMenuTests.cs b/src/DiffEngineTray.Core.Tests/TrayMenuTests.cs index 69afb961..51ed9eed 100644 --- a/src/DiffEngineTray.Core.Tests/TrayMenuTests.cs +++ b/src/DiffEngineTray.Core.Tests/TrayMenuTests.cs @@ -351,8 +351,9 @@ static void Render(StringBuilder builder, TrayMenu menu, IReadOnlyList - /// A head with no options window and no folder picker, as on Linux: run at login and the - /// update are on the menu, run at login checked as the desktop says, and there is no purge. + /// A head with no options window and no folder picker, as on a Linux desktop with neither + /// zenity nor kdialog: run at login and the update are on the menu, run at login checked as + /// the desktop says, and there is no purge. /// [Test] public async Task FixedForAHeadWithNoWindows() => diff --git a/src/DiffEngineTray.Core/DesktopDialog.cs b/src/DiffEngineTray.Core/DesktopDialog.cs new file mode 100644 index 00000000..bab7551f --- /dev/null +++ b/src/DiffEngineTray.Core/DesktopDialog.cs @@ -0,0 +1,161 @@ +/// +/// The two questions a purge asks, put by a program the desktop has, for a head with no toolkit +/// of its own to ask with: AppleScript's dialogs on macOS, and zenity's or kdialog's on Linux, as +/// pops a balloon. Each waits for as long as the question is +/// left unanswered, so neither is asked on the thread the tray's menu runs on. +/// +sealed class DesktopDialog( + string program, + IReadOnlyList folderArguments, + Func> confirmArguments) +{ + const string title = "Purge verified files"; + const string prompt = "The folder to delete every *.verified.* and *.received.* file under"; + + public string Program => program; + public IReadOnlyList FolderArguments => folderArguments; + public IReadOnlyList ConfirmArguments(string message) => confirmArguments(message); + + /// + /// Arguments go to the script as argv rather than into its text, so a path or a message with a + /// quote in it needs no escaping. + /// + public static DesktopDialog AppleScript { get; } = new( + "osascript", + [ + "-e", "on run argv", + "-e", "return POSIX path of (choose folder with prompt (item 1 of argv))", + "-e", "end run", + prompt + ], + message => + [ + "-e", "on run argv", + "-e", """display dialog (item 1 of argv) with title (item 2 of argv) buttons {"Cancel", "Delete"} default button "Delete" cancel button "Cancel" with icon caution""", + "-e", "end run", + message, + title + ]); + + /// + /// zenity reads a question's text as Pango markup, in which a path with an ampersand or an + /// angle bracket in it is an error, so markup is turned off. + /// + public static DesktopDialog Zenity { get; } = new( + "zenity", + ["--file-selection", "--directory", $"--title={prompt}"], + message => + [ + "--question", + "--no-markup", + $"--title={title}", + $"--text={message}", + "--ok-label=Delete", + "--cancel-label=Cancel" + ]); + + public static DesktopDialog Kdialog { get; } = new( + "kdialog", + ["--title", prompt, "--getexistingdirectory", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)], + message => + [ + "--title", title, + "--yes-label", "Delete", + "--no-label", "Cancel", + "--yesno", message + ]); + + /// + /// The one this platform has, or null for none to ask with. osascript is part of macOS. + /// + public static DesktopDialog? ForPlatform() + { + if (OperatingSystem.IsMacOS()) + { + return AppleScript; + } + + if (!OperatingSystem.IsLinux()) + { + return null; + } + + return ForLinux( + Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"), + _ => OsSettingsResolver.EnvPaths.Any(directory => File.Exists(Path.Combine(directory, _)))); + } + + /// + /// KDE's own on KDE, where zenity's would be a GTK window among Qt ones, and zenity's + /// everywhere else, either standing in for the other where only one is installed. + /// XDG_CURRENT_DESKTOP is a list, such as ubuntu:GNOME. + /// + internal static DesktopDialog? ForLinux(string? desktop, Func installed) + { + var kde = desktop? + .Split(':') + .Contains("KDE", StringComparer.OrdinalIgnoreCase) == true; + DesktopDialog[] order = kde ? [Kdialog, Zenity] : [Zenity, Kdialog]; + return order.FirstOrDefault(_ => installed(_.Program)); + } + + /// + /// The folder chosen, or null for none. All three exit with something other than 0 for a + /// cancel. + /// + public string? PickFolder() + { + var (code, output) = Run(FolderArguments); + if (code != 0) + { + return null; + } + + return FolderFrom(output); + } + + public bool Confirm(string message) => + Run(ConfirmArguments(message)).Code == 0; + + /// + /// The path as printed, a line of its own, and from AppleScript with a separator after it. + /// + internal static string? FolderFrom(string output) + { + var path = output.TrimEnd('\r', '\n'); + if (path.Length == 0) + { + return null; + } + + return Path.TrimEndingDirectorySeparator(path); + } + + /// + /// With no timeout, since the person is choosing. Standard error is read as well, though + /// nothing is done with it: zenity writes GTK's warnings there, and a pipe left full would + /// stop it before it answered. + /// + (int Code, string Output) Run(IReadOnlyList arguments) + { + var info = new ProcessStartInfo(program) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in arguments) + { + info.ArgumentList.Add(argument); + } + + using var process = Process.Start(info) ?? + throw new InvalidOperationException($"Could not start {program}"); + var error = process.StandardError.ReadToEndAsync(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + error.Wait(); + return (process.ExitCode, output); + } +} diff --git a/src/DiffEngineTray.Core/DesktopPurge.cs b/src/DiffEngineTray.Core/DesktopPurge.cs new file mode 100644 index 00000000..593a04c4 --- /dev/null +++ b/src/DiffEngineTray.Core/DesktopPurge.cs @@ -0,0 +1,103 @@ +/// +/// "Purge verified files" for a head with no toolkit: the folder and the go ahead asked through a +/// , and what came of it said through the head's notifications. What +/// it finds and deletes is the Windows head's (). A file that cannot +/// be deleted is passed over and counted, where the Windows head asks whether to try it again: a +/// Unix lock keeps nothing out, so what fails here is a permission, which asking again does not +/// change. +/// +static class DesktopPurge +{ + /// + /// The menu's action, or null where there is nothing to ask with. It returns at once: the + /// purge runs on a thread of its own, because each question waits on the person and the + /// menu's thread is the one the tray's loop turns on, which on macOS is AppKit's. + /// + public static Action? For(DesktopDialog? dialog, Action tell) + { + if (dialog is null) + { + return null; + } + + var running = 0; + return () => + { + // One at a time: a second chosen while the first waits on its dialog would put up another + if (Interlocked.Exchange(ref running, 1) == 1) + { + return; + } + + var thread = new Thread(() => + { + try + { + Run(dialog.PickFolder, dialog.Confirm, tell); + } + catch (Exception exception) + { + // Nothing above this catches, and an exception on a bare Thread ends the + // process, which is the tray and everything it holds + Log.Error(exception, "Failed to purge verified files"); + tell($"Failed to purge verified files. {exception.Message}"); + } + finally + { + Volatile.Write(ref running, 0); + } + }) + { + IsBackground = true, + Name = "Purge verified files" + }; + thread.Start(); + }; + } + + internal static void Run(Func pickFolder, Func confirm, Action tell) + { + if (pickFolder() is not { } path) + { + return; + } + + var files = VerifiedFiles.Find(path); + if (files.Length == 0) + { + tell($"No *.verified.* or *.received.* files found in {path}"); + return; + } + + if (!confirm( + $""" + Files found: {files.Length}, in {path}. + Delete files? + """)) + { + return; + } + + var failed = 0; + string? firstFailure = null; + foreach (var file in files) + { + var result = VerifiedFiles.TryDelete(file); + if (result.Deleted) + { + continue; + } + + failed++; + firstFailure ??= $"{file}: {result.Exception!.Message}"; + } + + if (failed == 0) + { + tell($"Deleted {files.Length} files from {path}."); + return; + } + + tell($"Deleted {files.Length - failed} of {files.Length} files from {path}. {failed} could not be deleted, the first being {firstFailure}"); + } +} diff --git a/src/DiffEngineTray.Core/TrayActions.cs b/src/DiffEngineTray.Core/TrayActions.cs index 2f7ebf1f..1f3c151c 100644 --- a/src/DiffEngineTray.Core/TrayActions.cs +++ b/src/DiffEngineTray.Core/TrayActions.cs @@ -3,7 +3,10 @@ /// start a move's diff tool, which on Windows brings the tool's window forward through user32. /// /// The head's options window, or null for a head with none. -/// The head's purge, or null for a head with no folder picker to start it. +/// +/// The head's purge, or null for a head with no folder picker to start it: a Linux desktop with +/// neither zenity nor kdialog (). +/// /// Handed the move as it is when the item is chosen, with an executable. /// /// For a head with no options window, run at login as a checked item on the menu, which is the one diff --git a/src/DiffEngineTray.Core/VerifiedFiles.cs b/src/DiffEngineTray.Core/VerifiedFiles.cs new file mode 100644 index 00000000..e80c35c1 --- /dev/null +++ b/src/DiffEngineTray.Core/VerifiedFiles.cs @@ -0,0 +1,63 @@ +/// +/// What "Purge verified files" finds and deletes, the same on every head: every +/// *.verified.* and *.received.* file under a folder. +/// +static class VerifiedFiles +{ + /// + /// SearchOption.AllDirectories is the compatibility enumeration: it does not ignore what it + /// cannot read, and it follows reparse points. A purge can be pointed at a profile folder or a + /// drive root, either of which holds a deny-ACL junction - Application Data, $Recycle.Bin - so + /// the scan reliably threw UnauthorizedAccessException before it had looked at a single file. + /// + static readonly EnumerationOptions enumeration = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + public static string[] Find(string path) => + [ + .. Directory.GetFiles(path, "*.verified.*", enumeration), + .. Directory.GetFiles(path, "*.received.*", enumeration) + ]; + + /// + /// A file another process holds open with no sharing cannot be deleted on Windows, so there + /// the processes holding it are ended and the delete tried once more. Elsewhere a lock keeps + /// nothing out, and the restart manager that would be asked is not there. + /// + public static DeleteResult TryDelete(string file) + { + try + { + if (File.Exists(file)) + { + File.Delete(file); + } + + return new(true, null); + } + catch (Exception exception) + { + if (OperatingSystem.IsWindows() && + FileLockKiller.KillLockingProcesses(file)) + { + try + { + File.Delete(file); + return new(true, null); + } + catch (Exception retryException) + { + return new(false, retryException); + } + } + + return new(false, exception); + } + } + + public record DeleteResult(bool Deleted, Exception? Exception); +} diff --git a/src/DiffEngineTray.Linux/Program.cs b/src/DiffEngineTray.Linux/Program.cs index e5d99294..5965c36a 100644 --- a/src/DiffEngineTray.Linux/Program.cs +++ b/src/DiffEngineTray.Linux/Program.cs @@ -1,8 +1,9 @@ /// /// The tray on Linux. What it does is the Windows head's, in the same order, less what only a -/// window or Windows can do: no hot keys, no options window and no purge, and no settings file -/// read, since nothing in it does anything here. Nothing kills a process holding a file either, a -/// Unix lock keeping nothing out. +/// window or Windows can do: no hot keys, no options window, and no settings file read, since +/// nothing in it does anything here. Purge asks through zenity or kdialog, and is not on the menu +/// where neither is installed. Nothing kills a process holding a file either, a Unix lock keeping +/// nothing out. /// static class Program { @@ -103,7 +104,7 @@ static async Task Inner() Exit: tokenSource.Cancel, Options: null, DebugView: () => DebugReportFile.Open(tracker), - Purge: null, + Purge: DesktopPurge.For(DesktopDialog.ForPlatform(), Warn), OpenDiffTool: DiffToolLauncher.Launch, RunAtLogin: RunAtLogin.ForPlatform(TrayLogin.Item), Update: TrayUpdate.MenuAction(tokenSource.Cancel)); diff --git a/src/DiffEngineTray.Mac/Program.cs b/src/DiffEngineTray.Mac/Program.cs index 2bcebdfd..5e3d1da4 100644 --- a/src/DiffEngineTray.Mac/Program.cs +++ b/src/DiffEngineTray.Mac/Program.cs @@ -107,7 +107,7 @@ static async Task Inner() Exit: tokenSource.Cancel, Options: null, DebugView: () => DebugReportFile.Open(tracker), - Purge: null, + Purge: DesktopPurge.For(DesktopDialog.ForPlatform(), Warn), OpenDiffTool: DiffToolLauncher.Launch, RunAtLogin: RunAtLogin.ForPlatform(TrayLogin.Item), Update: TrayUpdate.MenuAction(tokenSource.Cancel)); diff --git a/src/DiffEngineTray.Tests/FilePurgerTest.cs b/src/DiffEngineTray.Tests/FilePurgerTest.cs index a20d858a..40b23295 100644 --- a/src/DiffEngineTray.Tests/FilePurgerTest.cs +++ b/src/DiffEngineTray.Tests/FilePurgerTest.cs @@ -22,7 +22,7 @@ public async Task Find_SkipsInaccessibleDirectories() denied.SetAccessControl(security); try { - var found = FilePurger.Find(root.FullName); + var found = VerifiedFiles.Find(root.FullName); await Assert.That(found).HasSingleItem(); await Assert.That(Path.GetFileName(found[0])).IsEqualTo("a.verified.txt"); @@ -40,7 +40,7 @@ public async Task DeleteSucceeds_WhenFileNotLocked() var file = Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}.verified.txt"); await File.WriteAllTextAsync(file, "content"); - var result = FilePurger.TryDeleteWithLockKill(file); + var result = VerifiedFiles.TryDelete(file); await Assert.That(result.Deleted).IsTrue(); await Assert.That(result.Exception).IsNull(); @@ -52,7 +52,7 @@ public async Task DeleteSucceeds_WhenFileDoesNotExist() { var file = Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}.verified.txt"); - var result = FilePurger.TryDeleteWithLockKill(file); + var result = VerifiedFiles.TryDelete(file); await Assert.That(result.Deleted).IsTrue(); await Assert.That(result.Exception).IsNull(); @@ -70,7 +70,7 @@ public async Task DeleteSucceeds_WhenFileLocked_KillsLockingProcess() { await Assert.That(IsFileLocked(file)).IsTrue(); - var result = FilePurger.TryDeleteWithLockKill(file); + var result = VerifiedFiles.TryDelete(file); await Assert.That(result.Deleted).IsTrue(); await Assert.That(result.Exception).IsNull(); diff --git a/src/DiffEngineTray/FilePurger.cs b/src/DiffEngineTray/FilePurger.cs index dfff79c1..e09c729d 100644 --- a/src/DiffEngineTray/FilePurger.cs +++ b/src/DiffEngineTray/FilePurger.cs @@ -36,7 +36,7 @@ static void Inner() return; } - var files = Find(path); + var files = VerifiedFiles.Find(path); if (files.Length == 0) { @@ -50,26 +50,6 @@ static void Inner() } } - /// - /// SearchOption.AllDirectories is the compatibility enumeration: it does not ignore what it - /// cannot read, and it follows reparse points. The dialog lets the user pick a profile folder - /// or a drive root, either of which holds a deny-ACL junction - Application Data, - /// $Recycle.Bin - so the scan reliably threw UnauthorizedAccessException before it had looked - /// at a single file. - /// - static readonly EnumerationOptions enumeration = new() - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint - }; - - internal static string[] Find(string path) => - [ - .. Directory.GetFiles(path, "*.verified.*", enumeration), - .. Directory.GetFiles(path, "*.received.*", enumeration) - ]; - static bool Confirm(string[] files) { var result = AskQuestion( @@ -87,7 +67,7 @@ static void DeleteFiles(string[] files) for (var index = 0; index < files.Length; index++) { var file = files[index]; - var result = TryDeleteWithLockKill(file); + var result = VerifiedFiles.TryDelete(file); if (result.Deleted) { continue; @@ -113,38 +93,6 @@ static void DeleteFiles(string[] files) } } - internal static DeleteResult TryDeleteWithLockKill(string file) - { - try - { - if (File.Exists(file)) - { - File.Delete(file); - } - - return new(true, null); - } - catch (Exception exception) - { - if (FileLockKiller.KillLockingProcesses(file)) - { - try - { - File.Delete(file); - return new(true, null); - } - catch (Exception retryException) - { - return new(false, retryException); - } - } - - return new(false, exception); - } - } - - internal record DeleteResult(bool Deleted, Exception? Exception); - static DialogResult AskQuestion(string text, string caption, MessageBoxButtons buttons) => MessageBox.Show( text,