From 47a780d085dcd724b99a20bfc3224f7fae006b6b Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 31 Oct 2026 15:05:35 +1100 Subject: [PATCH] Purge verified files on macOS and Linux The two heads had no purge, since it needs a folder picker and neither has a toolkit. They now ask through a program the desktop has, as DesktopNotification pops a balloon: AppleScript's choose folder and display dialog on macOS, and on Linux zenity, or kdialog on KDE, either standing in for the other. With neither installed the item is not on the menu. DesktopPurge runs on a thread of its own, since each question waits on the person and the menu's thread is the tray loop's, which on macOS is AppKit's. A file it cannot delete is counted and the rest deleted, and a notification says what came of it; the Windows head still asks about each failure, since there it can be a file held open. What a purge finds and deletes moves out of the Windows head's FilePurger into the core's VerifiedFiles, and the delete asks the restart manager only on Windows, which is the only place it exists. --- claude.md | 7 +- docs/mdsource/tray.source.md | 6 +- docs/tray.md | 6 +- .../DesktopDialogTests.Arguments.verified.txt | 11 ++ .../DesktopDialogTests.cs | 55 ++++++ .../DesktopPurgeTests.cs | 123 +++++++++++++ .../TrayMenuTests.cs | 5 +- src/DiffEngineTray.Core/DesktopDialog.cs | 161 ++++++++++++++++++ src/DiffEngineTray.Core/DesktopPurge.cs | 103 +++++++++++ src/DiffEngineTray.Core/TrayActions.cs | 5 +- src/DiffEngineTray.Core/VerifiedFiles.cs | 63 +++++++ src/DiffEngineTray.Linux/Program.cs | 9 +- src/DiffEngineTray.Mac/Program.cs | 2 +- src/DiffEngineTray.Tests/FilePurgerTest.cs | 8 +- src/DiffEngineTray/FilePurger.cs | 56 +----- 15 files changed, 549 insertions(+), 71 deletions(-) create mode 100644 src/DiffEngineTray.Core.Tests/DesktopDialogTests.Arguments.verified.txt create mode 100644 src/DiffEngineTray.Core.Tests/DesktopDialogTests.cs create mode 100644 src/DiffEngineTray.Core.Tests/DesktopPurgeTests.cs create mode 100644 src/DiffEngineTray.Core/DesktopDialog.cs create mode 100644 src/DiffEngineTray.Core/DesktopPurge.cs create mode 100644 src/DiffEngineTray.Core/VerifiedFiles.cs diff --git a/claude.md b/claude.md index 2bc507b94..8da236835 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 cdfa171ff..ed10a6187 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 ff01b61f6..c70ba151b 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 000000000..b6078a06d --- /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 000000000..5887d9cf7 --- /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 000000000..0490069f1 --- /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 69afb961b..51ed9eed9 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 000000000..bab7551f3 --- /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 000000000..593a04c44 --- /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 2f7ebf1f6..1f3c151c2 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 000000000..e80c35c1d --- /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 e5d992948..5965c36a3 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 2bcebdfd0..5e3d1da4c 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 a20d858a5..40b23295d 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 dfff79c13..e09c729dd 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,